diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index 867e78dfad8dc..0611c80f59b92 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -28,7 +28,7 @@ import { delay } from 'bluebird'; import chromeDriver from 'chromedriver'; // @ts-ignore types not available import geckoDriver from 'geckodriver'; -import { Builder, Capabilities, logging } from 'selenium-webdriver'; +import { Builder, logging } from 'selenium-webdriver'; import chrome from 'selenium-webdriver/chrome'; import firefox from 'selenium-webdriver/firefox'; import edge from 'selenium-webdriver/edge'; diff --git a/test/scripts/jenkins_xpack_visual_regression.sh b/test/scripts/jenkins_xpack_visual_regression.sh index 726af43f28e31..ac567a188a6d4 100755 --- a/test/scripts/jenkins_xpack_visual_regression.sh +++ b/test/scripts/jenkins_xpack_visual_regression.sh @@ -22,5 +22,5 @@ yarn percy exec -t 10000 -- -- \ # cd "$KIBANA_DIR" # source "test/scripts/jenkins_xpack_page_load_metrics.sh" -cd "$XPACK_DIR" -source "$KIBANA_DIR/test/scripts/jenkins_xpack_saved_objects_field_metrics.sh" +cd "$KIBANA_DIR" +source "test/scripts/jenkins_xpack_saved_objects_field_metrics.sh" diff --git a/x-pack/plugins/actions/README.md b/x-pack/plugins/actions/README.md index 494f2f38e8bff..9e07727204f88 100644 --- a/x-pack/plugins/actions/README.md +++ b/x-pack/plugins/actions/README.md @@ -26,15 +26,19 @@ Table of Contents - [Executor](#executor) - [Example](#example) - [RESTful API](#restful-api) - - [`POST /api/actions/action`: Create action](#post-apiaction-create-action) - - [`DELETE /api/actions/action/{id}`: Delete action](#delete-apiactionid-delete-action) - - [`GET /api/actions`: Get all actions](#get-apiactiongetall-get-all-actions) - - [`GET /api/actions/action/{id}`: Get action](#get-apiactionid-get-action) - - [`GET /api/actions/list_action_types`: List action types](#get-apiactiontypes-list-action-types) - - [`PUT /api/actions/action/{id}`: Update action](#put-apiactionid-update-action) - - [`POST /api/actions/action/{id}/_execute`: Execute action](#post-apiactionidexecute-execute-action) + - [`POST /api/actions/action`: Create action](#post-apiactionsaction-create-action) + - [`DELETE /api/actions/action/{id}`: Delete action](#delete-apiactionsactionid-delete-action) + - [`GET /api/actions`: Get all actions](#get-apiactions-get-all-actions) + - [`GET /api/actions/action/{id}`: Get action](#get-apiactionsactionid-get-action) + - [`GET /api/actions/list_action_types`: List action types](#get-apiactionslist_action_types-list-action-types) + - [`PUT /api/actions/action/{id}`: Update action](#put-apiactionsactionid-update-action) + - [`POST /api/actions/action/{id}/_execute`: Execute action](#post-apiactionsactionid_execute-execute-action) - [Firing actions](#firing-actions) + - [Accessing a scoped ActionsClient](#accessing-a-scoped-actionsclient) + - [actionsClient.enqueueExecution(options)](#actionsclientenqueueexecutionoptions) - [Example](#example-1) + - [actionsClient.execute(options)](#actionsclientexecuteoptions) + - [Example](#example-2) - [Built-in Action Types](#built-in-action-types) - [Server log](#server-log) - [`config`](#config) @@ -70,6 +74,11 @@ Table of Contents - [`secrets`](#secrets-7) - [`params`](#params-7) - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1) + - [IBM Resilient](#ibm-resilient) + - [`config`](#config-8) + - [`secrets`](#secrets-8) + - [`params`](#params-8) + - [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2) - [Command Line Utility](#command-line-utility) - [Developing New Action Types](#developing-new-action-types) @@ -99,7 +108,7 @@ Built-In-Actions are configured using the _xpack.actions_ namespoace under _kiba | _xpack.actions._**enabled** | Feature toggle which enabled Actions in Kibana. | boolean | | _xpack.actions._**whitelistedHosts** | Which _hostnames_ are whitelisted for the Built-In-Action? This list should contain hostnames of every external service you wish to interact with using Webhooks, Email or any other built in Action. Note that you may use the string "\*" in place of a specific hostname to enable Kibana to target any URL, but keep in mind the potential use of such a feature to execute [SSRF](https://www.owasp.org/index.php/Server_Side_Request_Forgery) attacks from your server. | Array | | _xpack.actions._**enabledActionTypes** | A list of _actionTypes_ id's that are enabled. A "\*" may be used as an element to indicate all registered actionTypes should be enabled. The actionTypes registered for Kibana are `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, `.webhook`. Default: `["*"]` | Array | -| _xpack.actions._**preconfigured** | A object of action id / preconfigured actions. Default: `{}` | Array | +| _xpack.actions._**preconfigured** | A object of action id / preconfigured actions. Default: `{}` | Array | #### Whitelisting Built-in Action Types @@ -251,6 +260,7 @@ Once you have a scoped ActionsClient you can execute an action by caling either This api schedules a task which will run the action using the current user scope at the soonest opportunity. Running the action by scheduling a task means that we will no longer have a user request by which to ascertain the action's privileges and so you might need to provide these yourself: + - The **SpaceId** in which the user's action is expected to run - When security is enabled you'll also need to provide an **apiKey** which allows us to mimic the user and their privileges. @@ -287,14 +297,14 @@ This api runs the action and asynchronously returns the result of running the ac The following table describes the properties of the `options` object. -| Property | Description | Type | -| -------- | ------------------------------------------------------------------------------------------------------ | ------ | -| id | The id of the action you want to execute. | string | -| params | The `params` value to give the action type executor. | object | +| Property | Description | Type | +| -------- | ---------------------------------------------------- | ------ | +| id | The id of the action you want to execute. | string | +| params | The `params` value to give the action type executor. | object | ## Example -As with the previous example, we'll use the action `3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5` to send an email. +As with the previous example, we'll use the action `3c5b2bd4-5424-4e4b-8cf5-c0a58c762cc5` to send an email. ```typescript const actionsClient = await server.plugins.actions.getActionsClientWithRequest(request); @@ -559,10 +569,10 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla ### `config` -| Property | Description | Type | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -| apiUrl | ServiceNow instance URL. | string | -| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in ServiceNow and will be overwrite on each update. | object | +| Property | Description | Type | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | +| apiUrl | Jira instance URL. | string | +| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in Jira and will be overwrite on each update. | object | ### `secrets` @@ -588,6 +598,41 @@ The Jira action uses the [V2 API](https://developer.atlassian.com/cloud/jira/pla | comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | | externalId | The id of the incident in Jira. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | +## IBM Resilient + +ID: `.resilient` + +### `config` + +| Property | Description | Type | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | +| apiUrl | IBM Resilient instance URL. | string | +| casesConfiguration | Case configuration object. The object should contain an attribute called `mapping`. A `mapping` is an array of objects. Each mapping object should be of the form `{ source: string, target: string, actionType: string }`. `source` is the Case field. `target` is the Jira field where `source` will be mapped to. `actionType` can be one of `nothing`, `overwrite` or `append`. For example the `{ source: 'title', target: 'summary', actionType: 'overwrite' }` record, inside mapping array, means that the title of a case will be mapped to the short description of an incident in IBM Resilient and will be overwrite on each update. | object | + +### `secrets` + +| Property | Description | Type | +| ------------ | -------------------------------------------- | ------ | +| apiKeyId | API key ID for HTTP Basic authentication | string | +| apiKeySecret | API key secret for HTTP Basic authentication | string | + +### `params` + +| Property | Description | Type | +| --------------- | ------------------------------------------------------------------------------------ | ------ | +| subAction | The sub action to perform. It can be `pushToService`, `handshake`, and `getIncident` | string | +| subActionParams | The parameters of the sub action | object | + +#### `subActionParams (pushToService)` + +| Property | Description | Type | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| caseId | The case id | string | +| title | The title of the case | string _(optional)_ | +| description | The description of the case | string _(optional)_ | +| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }` | object[] _(optional)_ | +| externalId | The id of the incident in IBM Resilient. If presented the incident will be update. Otherwise a new incident will be created. | string _(optional)_ | + # Command Line Utility The [`kbn-action`](https://github.com/pmuellr/kbn-action) tool can be used to send HTTP requests to the Actions plugin. For instance, to create a Slack action from the `.slack` Action Type, use the following command: diff --git a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts index dbb18fa5c695c..2e3cee3946d61 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/case/utils.test.ts @@ -243,7 +243,7 @@ describe('transformFields', () => { }); }); - test('add newline character to descripton', () => { + test('add newline character to description', () => { const fields = prepareFieldsForTransformation({ externalCase: fullParams.externalCase, mapping: finalMapping, diff --git a/x-pack/plugins/actions/server/builtin_action_types/index.ts b/x-pack/plugins/actions/server/builtin_action_types/index.ts index 0020161789d71..80a171cbe624d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/index.ts @@ -16,6 +16,7 @@ import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; import { getActionType as getServiceNowActionType } from './servicenow'; import { getActionType as getJiraActionType } from './jira'; +import { getActionType as getResilientActionType } from './resilient'; export function registerBuiltInActionTypes({ actionsConfigUtils: configurationUtilities, @@ -34,4 +35,5 @@ export function registerBuiltInActionTypes({ actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getServiceNowActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getJiraActionType({ configurationUtilities })); + actionTypeRegistry.register(getResilientActionType({ configurationUtilities })); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts new file mode 100644 index 0000000000000..734f6be382629 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts @@ -0,0 +1,517 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { api } from '../case/api'; +import { externalServiceMock, mapping, apiParams } from './mocks'; +import { ExternalService } from '../case/types'; + +describe('api', () => { + let externalService: jest.Mocked; + + beforeEach(() => { + externalService = externalServiceMock.create(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('pushToService', () => { + describe('create incident', () => { + test('it creates an incident', async () => { + const params = { ...apiParams, externalId: null }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + ], + }); + }); + + test('it creates an incident without comments', async () => { + const params = { ...apiParams, externalId: null, comments: [] }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it calls createIncident correctly', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping, params }); + + expect(externalService.createIncident).toHaveBeenCalledWith({ + incident: { + description: + 'Incident description (created at 2020-06-03T15:09:13.606Z by Elastic User)', + name: 'Incident title (created at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + expect(externalService.updateIncident).not.toHaveBeenCalled(); + }); + + test('it calls createComment correctly', async () => { + const params = { ...apiParams, externalId: null }; + await api.pushToService({ externalService, mapping, params }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: '1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: '1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + }); + }); + + describe('update incident', () => { + test('it updates an incident', async () => { + const res = await api.pushToService({ externalService, mapping, params: apiParams }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-06-03T15:09:13.606Z', + }, + ], + }); + }); + + test('it updates an incident without comments', async () => { + const params = { ...apiParams, comments: [] }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it calls updateIncident correctly', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping, params }); + + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + expect(externalService.createIncident).not.toHaveBeenCalled(); + }); + + test('it calls createComment correctly', async () => { + const params = { ...apiParams }; + await api.pushToService({ externalService, mapping, params }); + expect(externalService.createComment).toHaveBeenCalledTimes(2); + expect(externalService.createComment).toHaveBeenNthCalledWith(1, { + incidentId: '1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: '1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-06-03T15:09:13.606Z by Elastic User)', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + }); + }); + + describe('mapping variations', () => { + test('overwrite & append', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('nothing & append', async () => { + mapping.set('title', { + target: 'name', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('append & append', async () => { + mapping.set('title', { + target: 'name', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: + 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'description from ibm resilient \r\nIncident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('nothing & nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: {}, + }); + }); + + test('overwrite & nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('overwrite & overwrite', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('nothing & overwrite', async () => { + mapping.set('title', { + target: 'name', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('append & overwrite', async () => { + mapping.set('title', { + target: 'name', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: + 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + description: + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('append & nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('name', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + name: + 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', + }, + }); + }); + + test('comment nothing', async () => { + mapping.set('title', { + target: 'name', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'nothing', + }); + + mapping.set('name', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.createComment).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts new file mode 100644 index 0000000000000..3db66e5884af4 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { api } from '../case/api'; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts new file mode 100644 index 0000000000000..4ce9417bfa9a1 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExternalServiceConfiguration } from '../case/types'; +import * as i18n from './translations'; + +export const config: ExternalServiceConfiguration = { + id: '.resilient', + name: i18n.NAME, + minimumLicenseRequired: 'platinum', +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts new file mode 100644 index 0000000000000..e98bc71559d3f --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createConnector } from '../case/utils'; + +import { api } from './api'; +import { config } from './config'; +import { validate } from './validators'; +import { createExternalService } from './service'; +import { ResilientSecretConfiguration, ResilientPublicConfiguration } from './schema'; + +export const getActionType = createConnector({ + api, + config, + validate, + createExternalService, + validationSchema: { + config: ResilientPublicConfiguration, + secrets: ResilientSecretConfiguration, + }, +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts new file mode 100644 index 0000000000000..bba9c58bf28c9 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ExternalService, + PushToServiceApiParams, + ExecutorSubActionPushParams, + MapRecord, +} from '../case/types'; + +const createMock = (): jest.Mocked => { + const service = { + getIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: '1', + name: 'title from ibm resilient', + description: 'description from ibm resilient', + discovered_date: 1589391874472, + create_date: 1591192608323, + inc_last_modified_date: 1591192650372, + }) + ), + createIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }) + ), + updateIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', + }) + ), + createComment: jest.fn(), + }; + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-1', + pushedDate: '2020-06-03T15:09:13.606Z', + externalCommentId: '1', + }) + ); + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-2', + pushedDate: '2020-06-03T15:09:13.606Z', + externalCommentId: '2', + }) + ); + + return service; +}; + +const externalServiceMock = { + create: createMock, +}; + +const mapping: Map> = new Map(); + +mapping.set('title', { + target: 'name', + actionType: 'overwrite', +}); + +mapping.set('description', { + target: 'description', + actionType: 'overwrite', +}); + +mapping.set('comments', { + target: 'comments', + actionType: 'append', +}); + +mapping.set('name', { + target: 'title', + actionType: 'overwrite', +}); + +const executorParams: ExecutorSubActionPushParams = { + savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + externalId: 'incident-3', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + createdAt: '2020-06-03T15:09:13.606Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-06-03T15:09:13.606Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + ], +}; + +const apiParams: PushToServiceApiParams = { + ...executorParams, + externalCase: { name: 'Incident title', description: 'Incident description' }, +}; + +export { externalServiceMock, mapping, executorParams, apiParams }; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts new file mode 100644 index 0000000000000..c13de2b27e2b9 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { ExternalIncidentServiceConfiguration } from '../case/schema'; + +export const ResilientPublicConfiguration = { + orgId: schema.string(), + ...ExternalIncidentServiceConfiguration, +}; + +export const ResilientPublicConfigurationSchema = schema.object(ResilientPublicConfiguration); + +export const ResilientSecretConfiguration = { + apiKeyId: schema.string(), + apiKeySecret: schema.string(), +}; + +export const ResilientSecretConfigurationSchema = schema.object(ResilientSecretConfiguration); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts new file mode 100644 index 0000000000000..573885698014e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts @@ -0,0 +1,422 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import axios from 'axios'; + +import { createExternalService, getValueTextContent, formatUpdateRequest } from './service'; +import * as utils from '../lib/axios_utils'; +import { ExternalService } from '../case/types'; + +jest.mock('axios'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; +const now = Date.now; +const TIMESTAMP = 1589391874472; + +// Incident update makes three calls to the API. +// The function below mocks this calls. +// a) Get the latest incident +// b) Update the incident +// c) Get the updated incident +const mockIncidentUpdate = (withUpdateError = false) => { + requestMock.mockImplementationOnce(() => ({ + data: { + id: '1', + name: 'title', + description: { + format: 'html', + content: 'description', + }, + }, + })); + + if (withUpdateError) { + requestMock.mockImplementationOnce(() => { + throw new Error('An error has occurred'); + }); + } else { + requestMock.mockImplementationOnce(() => ({ + data: { + success: true, + id: '1', + inc_last_modified_date: 1589391874472, + }, + })); + } + + requestMock.mockImplementationOnce(() => ({ + data: { + id: '1', + name: 'title_updated', + description: { + format: 'html', + content: 'desc_updated', + }, + inc_last_modified_date: 1589391874472, + }, + })); +}; + +describe('IBM Resilient service', () => { + let service: ExternalService; + + beforeAll(() => { + service = createExternalService({ + config: { apiUrl: 'https://resilient.elastic.co', orgId: '201' }, + secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' }, + }); + }); + + afterAll(() => { + Date.now = now; + }); + + beforeEach(() => { + jest.resetAllMocks(); + Date.now = jest.fn().mockReturnValue(TIMESTAMP); + }); + + describe('getValueTextContent', () => { + test('transforms correctly', () => { + expect(getValueTextContent('name', 'title')).toEqual({ + text: 'title', + }); + }); + + test('transforms correctly the description', () => { + expect(getValueTextContent('description', 'desc')).toEqual({ + textarea: { + format: 'html', + content: 'desc', + }, + }); + }); + }); + + describe('formatUpdateRequest', () => { + test('transforms correctly', () => { + const oldIncident = { name: 'title', description: 'desc' }; + const newIncident = { name: 'title_updated', description: 'desc_updated' }; + expect(formatUpdateRequest({ oldIncident, newIncident })).toEqual({ + changes: [ + { + field: { name: 'name' }, + old_value: { text: 'title' }, + new_value: { text: 'title_updated' }, + }, + { + field: { name: 'description' }, + old_value: { + textarea: { + format: 'html', + content: 'desc', + }, + }, + new_value: { + textarea: { + format: 'html', + content: 'desc_updated', + }, + }, + }, + ], + }); + }); + }); + + describe('createExternalService', () => { + test('throws without url', () => { + expect(() => + createExternalService({ + config: { apiUrl: null, orgId: '201' }, + secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, + }) + ).toThrow(); + }); + + test('throws without orgId', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com', orgId: null }, + secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, + }) + ).toThrow(); + }); + + test('throws without username', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com', orgId: '201' }, + secrets: { apiKeyId: '', apiKeySecret: 'secret' }, + }) + ).toThrow(); + }); + + test('throws without password', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com', orgId: '201' }, + secrets: { apiKeyId: '', apiKeySecret: undefined }, + }) + ).toThrow(); + }); + }); + + describe('getIncident', () => { + test('it returns the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + name: '1', + description: { + format: 'html', + content: 'description', + }, + }, + })); + const res = await service.getIncident('1'); + expect(res).toEqual({ id: '1', name: '1', description: 'description' }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { id: '1' }, + })); + + await service.getIncident('1'); + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1', + params: { + text_content_output_format: 'objects_convert', + }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + expect(service.getIncident('1')).rejects.toThrow( + 'Unable to get incident with id 1. Error: An error has occurred' + ); + }); + }); + + describe('createIncident', () => { + test('it creates the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, + })); + + const res = await service.createIncident({ + incident: { name: 'title', description: 'desc' }, + }); + + expect(res).toEqual({ + title: '1', + id: '1', + pushedDate: '2020-05-13T17:44:34.472Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, + })); + + await service.createIncident({ + incident: { name: 'title', description: 'desc' }, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://resilient.elastic.co/rest/orgs/201/incidents', + method: 'post', + data: { + name: 'title', + description: { + format: 'html', + content: 'desc', + }, + discovered_date: TIMESTAMP, + }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.createIncident({ + incident: { name: 'title', description: 'desc' }, + }) + ).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred' + ); + }); + }); + + describe('updateIncident', () => { + test('it updates the incident correctly', async () => { + mockIncidentUpdate(); + const res = await service.updateIncident({ + incidentId: '1', + incident: { name: 'title_updated', description: 'desc_updated' }, + }); + + expect(res).toEqual({ + title: '1', + id: '1', + pushedDate: '2020-05-13T17:44:34.472Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + test('it should call request with correct arguments', async () => { + mockIncidentUpdate(); + + await service.updateIncident({ + incidentId: '1', + incident: { name: 'title_updated', description: 'desc_updated' }, + }); + + // Incident update makes three calls to the API. + // The second call to the API is the update call. + expect(requestMock.mock.calls[1][0]).toEqual({ + axios, + method: 'patch', + url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1', + data: { + changes: [ + { + field: { name: 'name' }, + old_value: { text: 'title' }, + new_value: { text: 'title_updated' }, + }, + { + field: { name: 'description' }, + old_value: { + textarea: { + content: 'description', + format: 'html', + }, + }, + new_value: { + textarea: { + content: 'desc_updated', + format: 'html', + }, + }, + }, + ], + }, + }); + }); + + test('it should throw an error', async () => { + mockIncidentUpdate(true); + + expect( + service.updateIncident({ + incidentId: '1', + incident: { name: 'title', description: 'desc' }, + }) + ).rejects.toThrow( + '[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred' + ); + }); + }); + + describe('createComment', () => { + test('it creates the comment correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + create_date: 1589391874472, + }, + })); + + const res = await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'comments', + }); + + expect(res).toEqual({ + commentId: 'comment-1', + pushedDate: '2020-05-13T17:44:34.472Z', + externalCommentId: '1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + create_date: 1589391874472, + }, + })); + + await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'my_field', + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + method: 'post', + url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1/comments', + data: { + text: { + content: 'comment', + format: 'text', + }, + }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'comments', + }) + ).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: An error has occurred' + ); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts new file mode 100644 index 0000000000000..8d0526ca3b571 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import axios from 'axios'; + +import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types'; +import { + ResilientPublicConfigurationType, + ResilientSecretConfigurationType, + CreateIncidentRequest, + UpdateIncidentRequest, + CreateCommentRequest, + UpdateFieldText, + UpdateFieldTextArea, +} from './types'; + +import * as i18n from './translations'; +import { getErrorMessage, request } from '../lib/axios_utils'; + +const BASE_URL = `rest`; +const INCIDENT_URL = `incidents`; +const COMMENT_URL = `comments`; + +const VIEW_INCIDENT_URL = `#incidents`; + +export const getValueTextContent = ( + field: string, + value: string +): UpdateFieldText | UpdateFieldTextArea => { + if (field === 'description') { + return { + textarea: { + format: 'html', + content: value, + }, + }; + } + + return { + text: value, + }; +}; + +export const formatUpdateRequest = ({ + oldIncident, + newIncident, +}: ExternalServiceParams): UpdateIncidentRequest => { + return { + changes: Object.keys(newIncident).map((key) => ({ + field: { name: key }, + old_value: getValueTextContent(key, oldIncident[key]), + new_value: getValueTextContent(key, newIncident[key]), + })), + }; +}; + +export const createExternalService = ({ + config, + secrets, +}: ExternalServiceCredentials): ExternalService => { + const { apiUrl: url, orgId } = config as ResilientPublicConfigurationType; + const { apiKeyId, apiKeySecret } = secrets as ResilientSecretConfigurationType; + + if (!url || !orgId || !apiKeyId || !apiKeySecret) { + throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); + } + + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + const incidentUrl = `${urlWithoutTrailingSlash}/${BASE_URL}/orgs/${orgId}/${INCIDENT_URL}`; + const commentUrl = `${incidentUrl}/{inc_id}/${COMMENT_URL}`; + const axiosInstance = axios.create({ + auth: { username: apiKeyId, password: apiKeySecret }, + }); + + const getIncidentViewURL = (key: string) => { + return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}/${key}`; + }; + + const getCommentsURL = (incidentId: string) => { + return commentUrl.replace('{inc_id}', incidentId); + }; + + const getIncident = async (id: string) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}/${id}`, + params: { + text_content_output_format: 'objects_convert', + }, + }); + + return { ...res.data, description: res.data.description?.content ?? '' }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}`) + ); + } + }; + + const createIncident = async ({ incident }: ExternalServiceParams) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}`, + method: 'post', + data: { + ...incident, + description: { + format: 'html', + content: incident.description ?? '', + }, + discovered_date: Date.now(), + }, + }); + + return { + title: `${res.data.id}`, + id: `${res.data.id}`, + pushedDate: new Date(res.data.create_date).toISOString(), + url: getIncidentViewURL(res.data.id), + }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`) + ); + } + }; + + const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + try { + const latestIncident = await getIncident(incidentId); + + const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident: incident }); + const res = await request({ + axios: axiosInstance, + method: 'patch', + url: `${incidentUrl}/${incidentId}`, + data, + }); + + if (!res.data.success) { + throw new Error(res.data.message); + } + + const updatedIncident = await getIncident(incidentId); + + return { + title: `${updatedIncident.id}`, + id: `${updatedIncident.id}`, + pushedDate: new Date(updatedIncident.inc_last_modified_date).toISOString(), + url: getIncidentViewURL(updatedIncident.id), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to update incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + const createComment = async ({ incidentId, comment, field }: ExternalServiceParams) => { + try { + const res = await request({ + axios: axiosInstance, + method: 'post', + url: getCommentsURL(incidentId), + data: { text: { format: 'text', content: comment.comment } }, + }); + + return { + commentId: comment.commentId, + externalCommentId: res.data.id, + pushedDate: new Date(res.data.create_date).toISOString(), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}` + ) + ); + } + }; + + return { + getIncident, + createIncident, + updateIncident, + createComment, + }; +}; diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts new file mode 100644 index 0000000000000..d952838d5a2b3 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const NAME = i18n.translate('xpack.actions.builtin.case.resilientTitle', { + defaultMessage: 'IBM Resilient', +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts new file mode 100644 index 0000000000000..6869e2ff3a105 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { ResilientPublicConfigurationSchema, ResilientSecretConfigurationSchema } from './schema'; + +export type ResilientPublicConfigurationType = TypeOf; +export type ResilientSecretConfigurationType = TypeOf; + +interface CreateIncidentBasicRequestArgs { + name: string; + description: string; + discovered_date: number; +} + +interface Comment { + text: { format: string; content: string }; +} + +interface CreateIncidentRequestArgs extends CreateIncidentBasicRequestArgs { + comments?: Comment[]; +} + +export interface UpdateFieldText { + text: string; +} + +export interface UpdateFieldTextArea { + textarea: { format: 'html' | 'text'; content: string }; +} + +interface UpdateField { + field: { name: string }; + old_value: UpdateFieldText | UpdateFieldTextArea; + new_value: UpdateFieldText | UpdateFieldTextArea; +} + +export type CreateIncidentRequest = CreateIncidentRequestArgs; +export type CreateCommentRequest = Comment; + +export interface UpdateIncidentRequest { + changes: UpdateField[]; +} diff --git a/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts new file mode 100644 index 0000000000000..7226071392bc6 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validateCommonConfig, validateCommonSecrets } from '../case/validators'; +import { ExternalServiceValidation } from '../case/types'; + +export const validate: ExternalServiceValidation = { + config: validateCommonConfig, + secrets: validateCommonSecrets, +}; diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index 7d20011a428cf..38fff5b190f25 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -10,6 +10,7 @@ import { ActionResult } from '../../../../actions/common'; import { UserRT } from '../user'; import { JiraFieldsRT } from '../connectors/jira'; import { ServiceNowFieldsRT } from '../connectors/servicenow'; +import { ResilientFieldsRT } from '../connectors/resilient'; /* * This types below are related to the service now configuration @@ -29,7 +30,12 @@ const CaseFieldRT = rt.union([ rt.literal('comments'), ]); -const ThirdPartyFieldRT = rt.union([JiraFieldsRT, ServiceNowFieldsRT, rt.literal('not_mapped')]); +const ThirdPartyFieldRT = rt.union([ + JiraFieldsRT, + ServiceNowFieldsRT, + ResilientFieldsRT, + rt.literal('not_mapped'), +]); export const CasesConfigurationMapsRT = rt.type({ source: CaseFieldRT, diff --git a/x-pack/plugins/case/common/api/connectors/index.ts b/x-pack/plugins/case/common/api/connectors/index.ts index c1fc284c938b7..0a7840d3aba22 100644 --- a/x-pack/plugins/case/common/api/connectors/index.ts +++ b/x-pack/plugins/case/common/api/connectors/index.ts @@ -6,3 +6,4 @@ export * from './jira'; export * from './servicenow'; +export * from './resilient'; diff --git a/x-pack/plugins/case/common/api/connectors/resilient.ts b/x-pack/plugins/case/common/api/connectors/resilient.ts new file mode 100644 index 0000000000000..c7e2f19809140 --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/resilient.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const ResilientFieldsRT = rt.union([ + rt.literal('name'), + rt.literal('description'), + rt.literal('comments'), +]); + +export type ResilientFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index e912c661439b2..bd12c258a5388 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -29,4 +29,4 @@ export const ACTION_URL = '/api/actions'; export const ACTION_TYPES_URL = '/api/actions/list_action_types'; export const SERVICENOW_ACTION_TYPE_ID = '.servicenow'; -export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira']; +export const SUPPORTED_CONNECTORS = ['.servicenow', '.jira', '.resilient']; diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index 91a5aa5c88beb..7974116f4dc43 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -166,6 +166,9 @@ describe('ConfigureCases', () => { expect.objectContaining({ id: '.jira', }), + expect.objectContaining({ + id: '.resilient', + }), ]); expect(wrapper.find(ConnectorEditFlyout).prop('editFlyoutVisible')).toBe(false); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts index 0b19e4177f5c2..833f85712b5fa 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/config.ts @@ -7,9 +7,11 @@ // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { ServiceNowConnectorConfiguration } from '../../../../../triggers_actions_ui/public/common'; import { connector as jiraConnectorConfig } from './jira/config'; +import { connector as resilientConnectorConfig } from './resilient/config'; import { ConnectorConfiguration } from './types'; export const connectorsConfiguration: Record = { '.servicenow': ServiceNowConnectorConfiguration as ConnectorConfiguration, '.jira': jiraConnectorConfig, + '.resilient': resilientConnectorConfig, }; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts index 83b07a2905ef0..f32e1e0df184e 100644 --- a/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/index.ts @@ -5,3 +5,4 @@ */ export { getActionType as jiraActionType } from './jira'; +export { getActionType as resilientActionType } from './resilient'; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts new file mode 100644 index 0000000000000..7d4edbf624877 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ConnectorConfiguration } from './types'; + +import * as i18n from './translations'; +import logo from './logo.svg'; + +export const connector: ConnectorConfiguration = { + id: '.resilient', + name: i18n.RESILIENT_TITLE, + logo, + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'platinum', + fields: { + name: { + label: i18n.MAPPING_FIELD_NAME, + validSourceFields: ['title', 'description'], + defaultSourceField: 'title', + defaultActionType: 'overwrite', + }, + description: { + label: i18n.MAPPING_FIELD_DESC, + validSourceFields: ['title', 'description'], + defaultSourceField: 'description', + defaultActionType: 'overwrite', + }, + comments: { + label: i18n.MAPPING_FIELD_COMMENTS, + validSourceFields: ['comments'], + defaultSourceField: 'comments', + defaultActionType: 'append', + }, + }, +}; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx new file mode 100644 index 0000000000000..31bf0a4dfc34b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiFieldPassword, + EuiSpacer, +} from '@elastic/eui'; + +import * as i18n from './translations'; +import { ConnectorFlyoutFormProps } from '../types'; +import { ResilientActionConnector } from './types'; +import { withConnectorFlyout } from '../components/connector_flyout'; + +const resilientConnectorForm: React.FC> = ({ + errors, + action, + onChangeSecret, + onBlurSecret, + onChangeConfig, + onBlurConfig, +}) => { + const { orgId } = action.config; + const { apiKeyId, apiKeySecret } = action.secrets; + const isOrgIdInvalid: boolean = errors.orgId.length > 0 && orgId != null; + const isApiKeyIdInvalid: boolean = errors.apiKeyId.length > 0 && apiKeyId != null; + const isApiKeySecretInvalid: boolean = errors.apiKeySecret.length > 0 && apiKeySecret != null; + + return ( + <> + + + + onChangeConfig('orgId', evt.target.value)} + onBlur={() => onBlurConfig('orgId')} + /> + + + + + + + + onChangeSecret('apiKeyId', evt.target.value)} + onBlur={() => onBlurSecret('apiKeyId')} + /> + + + + + + + + onChangeSecret('apiKeySecret', evt.target.value)} + onBlur={() => onBlurSecret('apiKeySecret')} + /> + + + + + ); +}; + +export const resilientConnectorFlyout = withConnectorFlyout({ + ConnectorFormComponent: resilientConnectorForm, + secretKeys: ['apiKeyId', 'apiKeySecret'], + configKeys: ['orgId'], + connectorActionTypeId: '.resilient', +}); + +// eslint-disable-next-line import/no-default-export +export { resilientConnectorFlyout as default }; diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx new file mode 100644 index 0000000000000..d3daf195582a8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { lazy } from 'react'; +import { + ValidationResult, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../triggers_actions_ui/public/types'; + +import { connector } from './config'; +import { createActionType } from '../utils'; +import logo from './logo.svg'; +import { ResilientActionConnector } from './types'; +import * as i18n from './translations'; + +interface Errors { + orgId: string[]; + apiKeyId: string[]; + apiKeySecret: string[]; +} + +const validateConnector = (action: ResilientActionConnector): ValidationResult => { + const errors: Errors = { + orgId: [], + apiKeyId: [], + apiKeySecret: [], + }; + + if (!action.config.orgId) { + errors.orgId = [...errors.orgId, i18n.RESILIENT_PROJECT_KEY_LABEL]; + } + + if (!action.secrets.apiKeyId) { + errors.apiKeyId = [...errors.apiKeyId, i18n.RESILIENT_API_KEY_ID_REQUIRED]; + } + + if (!action.secrets.apiKeySecret) { + errors.apiKeySecret = [...errors.apiKeySecret, i18n.RESILIENT_API_KEY_SECRET_REQUIRED]; + } + + return { errors }; +}; + +export const getActionType = createActionType({ + id: connector.id, + iconClass: logo, + selectMessage: i18n.RESILIENT_DESC, + actionTypeTitle: connector.name, + validateConnector, + actionConnectorFields: lazy(() => import('./flyout')), +}); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg new file mode 100644 index 0000000000000..553c2c62b7191 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts new file mode 100644 index 0000000000000..f8aec2eea3d4b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../translations'; + +export const RESILIENT_DESC = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.selectMessageText', + { + defaultMessage: 'Push or update SIEM case data to a new issue in resilient', + } +); + +export const RESILIENT_TITLE = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.actionTypeTitle', + { + defaultMessage: 'IBM Resilient', + } +); + +export const RESILIENT_PROJECT_KEY_LABEL = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.orgId', + { + defaultMessage: 'Organization Id', + } +); + +export const RESILIENT_PROJECT_KEY_REQUIRED = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.requiredOrgIdTextField', + { + defaultMessage: 'Organization Id', + } +); + +export const RESILIENT_API_KEY_ID_LABEL = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.apiKeyId', + { + defaultMessage: 'API key id', + } +); + +export const RESILIENT_API_KEY_ID_REQUIRED = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.requiredApiKeyIdTextField', + { + defaultMessage: 'API key id is required', + } +); + +export const RESILIENT_API_KEY_SECRET_LABEL = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.apiKeySecret', + { + defaultMessage: 'API key secret', + } +); + +export const RESILIENT_API_KEY_SECRET_REQUIRED = i18n.translate( + 'xpack.securitySolution.case.connectors.resilient.requiredApiKeySecretTextField', + { + defaultMessage: 'API key secret is required', + } +); + +export const MAPPING_FIELD_NAME = i18n.translate( + 'xpack.securitySolution.case.configureCases.mappingFieldName', + { + defaultMessage: 'Name', + } +); diff --git a/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts new file mode 100644 index 0000000000000..fe6dbb2b3674a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-restricted-imports */ +/* eslint-disable @kbn/eslint/no-restricted-paths */ + +import { + ResilientPublicConfigurationType, + ResilientSecretConfigurationType, +} from '../../../../../../actions/server/builtin_action_types/resilient/types'; + +export { ResilientFieldsType } from '../../../../../../case/common/api/connectors'; + +export * from '../types'; + +export interface ResilientActionConnector { + config: ResilientPublicConfigurationType; + secrets: ResilientSecretConfigurationType; +} diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 6096a9b0e0bb8..7bb4be6b50879 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -22,7 +22,7 @@ import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import { initTelemetry } from './common/lib/telemetry'; import { KibanaServices } from './common/lib/kibana/services'; -import { jiraActionType } from './common/lib/connectors'; +import { jiraActionType, resilientActionType } from './common/lib/connectors'; import { PluginSetup, PluginStart, @@ -84,6 +84,7 @@ export class Plugin implements IPlugin { const storage = new Storage(localStorage); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index acde455f77cb4..1a19306b2fd60 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -139,6 +139,139 @@ describe('buildEventTypeSignal', () => { }); }); + test('it should deduplicate exception entries', async () => { + const testEntries: EntriesArray = [ + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, + { + field: 'host.hostname.text', + operator: 'included', + type: 'match_any', + value: ['estc', 'kibana'], + }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'server.domain', + operator: 'included', + type: 'exact_caseless', + value: 'DOMAIN', + }, + { + field: 'server.ip', + operator: 'included', + type: 'exact_cased', + value: '192.168.1.1', + }, + { + field: 'host.hostname', + operator: 'included', + type: 'exact_caseless_any', + value: ['estc', 'kibana'], + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should not deduplicate exception entries across nested boundaries', async () => { + const testEntries: EntriesArray = [ + { + entries: [ + { field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }, + ], + field: 'some.parentField', + type: 'nested', + }, + // Same as above but not inside the nest + { field: 'nested.field', operator: 'included', type: 'match', value: 'some value' }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + entries: [ + { + field: 'nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + field: 'some.parentField', + type: 'nested', + }, + { + field: 'nested.field', + operator: 'included', + type: 'exact_cased', + value: 'some value', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + + test('it should deduplicate exception items', async () => { + const testEntries: EntriesArray = [ + { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, + ]; + + const expectedEndpointExceptions = { + type: 'simple', + entries: [ + { + field: 'server.domain', + operator: 'included', + type: 'exact_caseless', + value: 'DOMAIN', + }, + { + field: 'server.ip', + operator: 'included', + type: 'exact_cased', + value: '192.168.1.1', + }, + ], + }; + + const first = getFoundExceptionListItemSchemaMock(); + first.data[0].entries = testEntries; + + // Create a second exception item with the same entries + first.data[1] = getExceptionListItemSchemaMock(); + first.data[1].entries = testEntries; + mockExceptionClient.findExceptionListItem = jest.fn().mockReturnValueOnce(first); + + const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); + expect(resp).toEqual({ + entries: [expectedEndpointExceptions], + }); + }); + test('it should ignore unsupported entries', async () => { // Lists and exists are not supported by the Endpoint const testEntries: EntriesArray = [ @@ -178,8 +311,9 @@ describe('buildEventTypeSignal', () => { }); test('it should convert the exception lists response to the proper endpoint format while paging', async () => { - // The first call returns one exception + // The first call returns two exceptions const first = getFoundExceptionListItemSchemaMock(); + first.data.push(getExceptionListItemSchemaMock()); // The second call returns two exceptions const second = getFoundExceptionListItemSchemaMock(); @@ -194,7 +328,8 @@ describe('buildEventTypeSignal', () => { .mockReturnValueOnce(second) .mockReturnValueOnce(third); const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); - expect(resp.entries.length).toEqual(3); + // Expect 2 exceptions, the first two calls returned the same exception list items + expect(resp.entries.length).toEqual(2); }); test('it should handle no exceptions', async () => { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 556405adff62f..b756c4e3d14c3 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -97,10 +97,18 @@ export function translateToEndpointExceptions( exc: FoundExceptionListItemSchema, schemaVersion: string ): TranslatedExceptionListItem[] { + const entrySet = new Set(); + const entriesFiltered: TranslatedExceptionListItem[] = []; if (schemaVersion === 'v1') { - return exc.data.map((item) => { - return translateItem(schemaVersion, item); + exc.data.forEach((entry) => { + const translatedItem = translateItem(schemaVersion, entry); + const entryHash = createHash('sha256').update(JSON.stringify(translatedItem)).digest('hex'); + if (!entrySet.has(entryHash)) { + entriesFiltered.push(translatedItem); + entrySet.add(entryHash); + } }); + return entriesFiltered; } else { throw new Error('unsupported schemaVersion'); } @@ -124,12 +132,17 @@ function translateItem( schemaVersion: string, item: ExceptionListItemSchema ): TranslatedExceptionListItem { + const itemSet = new Set(); return { type: item.type, entries: item.entries.reduce((translatedEntries: TranslatedEntry[], entry) => { const translatedEntry = translateEntry(schemaVersion, entry); if (translatedEntry !== undefined && translatedEntryType.is(translatedEntry)) { - translatedEntries.push(translatedEntry); + const itemHash = createHash('sha256').update(JSON.stringify(translatedEntry)).digest('hex'); + if (!itemSet.has(itemHash)) { + translatedEntries.push(translatedEntry); + itemSet.add(itemHash); + } } return translatedEntries; }, []), diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 0877fdc949dc4..e3281cfdfa9a3 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -25,6 +25,7 @@ const enabledActionTypes = [ '.server-log', '.servicenow', '.jira', + '.resilient', '.slack', '.webhook', 'test.authorization', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index f1ac3f91c68db..b8b2cbdc03f39 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -12,6 +12,7 @@ import { ActionType } from '../../../../../../../plugins/actions/server'; import { initPlugin as initPagerduty } from './pagerduty_simulation'; import { initPlugin as initServiceNow } from './servicenow_simulation'; import { initPlugin as initJira } from './jira_simulation'; +import { initPlugin as initResilient } from './resilient_simulation'; export const NAME = 'actions-FTS-external-service-simulators'; @@ -20,6 +21,7 @@ export enum ExternalServiceSimulator { SERVICENOW = 'servicenow', SLACK = 'slack', JIRA = 'jira', + RESILIENT = 'resilient', WEBHOOK = 'webhook', } @@ -33,6 +35,7 @@ export function getAllExternalServiceSimulatorPaths(): string[] { ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.JIRA}/rest/api/2/issue`); + allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.RESILIENT}/rest/orgs/201/incidents`); return allPaths; } @@ -88,6 +91,7 @@ export class FixturePlugin implements Plugin, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + id: '123', + create_date: 1589391874472, + }); + } + ); + + router.patch( + { + path: `${path}/rest/orgs/201/incidents/{id}`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + success: true, + }); + } + ); + + router.get( + { + path: `${path}/rest/orgs/201/incidents/{id}`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + id: '123', + create_date: 1589391874472, + inc_last_modified_date: 1589391874472, + name: 'title', + description: 'description', + }); + } + ); + + router.post( + { + path: `${path}/rest/api/2/issue/{id}/comment`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return jsonResponse(res, 200, { + id: '123', + created: '2020-04-27T14:17:45.490Z', + }); + } + ); +} + +function jsonResponse( + res: KibanaResponseFactory, + code: number, + object: Record = {} +) { + return res.custom>({ body: object, statusCode: code }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts new file mode 100644 index 0000000000000..a77e0414a19d4 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts @@ -0,0 +1,549 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + getExternalServiceSimulatorPath, + ExternalServiceSimulator, +} from '../../../../common/fixtures/plugins/actions_simulators/server/plugin'; + +const mapping = [ + { + source: 'title', + target: 'name', + actionType: 'overwrite', + }, + { + source: 'description', + target: 'description', + actionType: 'overwrite', + }, + { + source: 'comments', + target: 'comments', + actionType: 'append', + }, +]; + +// eslint-disable-next-line import/no-default-export +export default function resilientTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + + const mockResilient = { + config: { + apiUrl: 'www.jiraisinkibanaactions.com', + orgId: '201', + casesConfiguration: { mapping }, + }, + secrets: { + apiKeyId: 'key', + apiKeySecret: 'secret', + }, + params: { + subAction: 'pushToService', + subActionParams: { + savedObjectId: '123', + title: 'a title', + description: 'a description', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + externalId: null, + comments: [ + { + commentId: '456', + version: 'WzU3LDFd', + comment: 'first comment', + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: null, + updatedBy: null, + }, + ], + }, + }, + }; + + let resilientSimulatorURL: string = ''; + + describe('IBM Resilient', () => { + before(() => { + resilientSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.RESILIENT) + ); + }); + + after(() => esArchiver.unload('empty_kibana')); + + describe('IBM Resilient - Action Creation', () => { + it('should return 200 when creating a ibm resilient action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient action', + actionTypeId: '.resilient', + config: { + ...mockResilient.config, + apiUrl: resilientSimulatorURL, + }, + secrets: mockResilient.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + isPreconfigured: false, + name: 'An IBM Resilient action', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + }); + + const { body: fetchedAction } = await supertest + .get(`/api/actions/action/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + isPreconfigured: false, + name: 'An IBM Resilient action', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with no apiUrl', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { orgId: '201' }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with no orgId', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { apiUrl: resilientSimulatorURL }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [orgId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with a non whitelisted apiUrl', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: 'http://resilient.mynonexistent.com', + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + secrets: mockResilient.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error configuring connector action: target url "http://resilient.mynonexistent.com" is not whitelisted in the Kibana config xpack.actions.whitelistedHosts', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action without secrets', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [apiKeyId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action without casesConfiguration', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + }, + secrets: mockResilient.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [casesConfiguration.mapping]: expected value of type [array] but got [undefined]', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with empty mapping', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: { mapping: [] }, + }, + secrets: mockResilient.secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [casesConfiguration.mapping]: expected non-empty but got empty', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a ibm resilient action with wrong actionType', async () => { + await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An IBM Resilient', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: { + mapping: [ + { + source: 'title', + target: 'description', + actionType: 'non-supported', + }, + ], + }, + }, + secrets: mockResilient.secrets, + }) + .expect(400); + }); + }); + + describe('IBM Resilient - Executor', () => { + let simulatedActionId: string; + before(async () => { + const { body } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A ibm resilient simulator', + actionTypeId: '.resilient', + config: { + apiUrl: resilientSimulatorURL, + orgId: mockResilient.config.orgId, + casesConfiguration: mockResilient.config.casesConfiguration, + }, + secrets: mockResilient.secrets, + }); + simulatedActionId = body.id; + }); + + describe('Validation', () => { + it('should handle failing with a simulated success without action', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: `error validating action params: Cannot read property 'Symbol(Symbol.iterator)' of undefined`, + }); + }); + }); + + it('should handle failing with a simulated success without unsupported action', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'non-supported' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subAction]: expected value to equal [pushToService]', + }); + }); + }); + + it('should handle failing with a simulated success without subActionParams', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService' }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without savedObjectId', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'pushToService', subActionParams: {} }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.savedObjectId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without title', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + savedObjectId: 'success', + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.title]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without createdAt', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + savedObjectId: 'success', + title: 'success', + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.createdAt]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without commentId', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + savedObjectId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{}], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + + it('should handle failing with a simulated success without comment message', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + savedObjectId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{ commentId: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + + it('should handle failing with a simulated success without comment.createdAt', async () => { + await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + savedObjectId: 'success', + title: 'success', + createdAt: 'success', + createdBy: { username: 'elastic' }, + comments: [{ commentId: 'success', comment: 'success' }], + }, + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getIncident]\n- [1.subAction]: expected value to equal [handshake]\n- [2.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.createdAt]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]', + }); + }); + }); + }); + + describe('Execution', () => { + it('should handle creating an incident without comments', async () => { + const { body } = await supertest + .post(`/api/actions/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + ...mockResilient.params, + subActionParams: { + ...mockResilient.params.subActionParams, + comments: [], + }, + }, + }) + .expect(200); + + expect(body).to.eql({ + status: 'ok', + actionId: simulatedActionId, + data: { + id: '123', + title: '123', + pushedDate: '2020-05-13T17:44:34.472Z', + url: `${resilientSimulatorURL}/#incidents/123`, + }, + }); + }); + }); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index 18b1714582d13..9cdc0c9fa663e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -16,6 +16,7 @@ export default function actionsTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./builtin_action_types/server_log')); loadTestFile(require.resolve('./builtin_action_types/servicenow')); loadTestFile(require.resolve('./builtin_action_types/jira')); + loadTestFile(require.resolve('./builtin_action_types/resilient')); loadTestFile(require.resolve('./builtin_action_types/slack')); loadTestFile(require.resolve('./builtin_action_types/webhook')); loadTestFile(require.resolve('./create'));