From 1235f343aa02757a8e7e223da1e993e380b66c96 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 13 May 2020 14:03:49 +0300 Subject: [PATCH 01/18] Create schema and types --- .../builtin_action_types/resilient/schema.ts | 22 ++++++++++++++ .../builtin_action_types/resilient/types.ts | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/schema.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts 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/types.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts new file mode 100644 index 0000000000000..614619bbd2f9d --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts @@ -0,0 +1,29 @@ +/* + * 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 type CreateIncidentRequest = CreateIncidentRequestArgs; + +export type UpdateIncidentRequest = Partial; From adca16ca4d37801c29e269595551beaa680f88f6 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 13 May 2020 14:13:11 +0300 Subject: [PATCH 02/18] Add resilient fields to API --- x-pack/plugins/case/common/api/cases/configure.ts | 8 +++++++- .../plugins/case/common/api/connectors/index.ts | 1 + .../case/common/api/connectors/resilient.ts | 15 +++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/case/common/api/connectors/resilient.ts 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; From 10f91fb62b50f88eb1764caf31e211b931ab5532 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 13 May 2020 14:38:42 +0300 Subject: [PATCH 03/18] Create flyout --- .../public/common/lib/connectors/config.ts | 2 + .../public/common/lib/connectors/index.ts | 1 + .../common/lib/connectors/resilient/config.ts | 40 ++++++ .../lib/connectors/resilient/flyout.tsx | 114 ++++++++++++++++++ .../common/lib/connectors/resilient/index.tsx | 54 +++++++++ .../common/lib/connectors/resilient/logo.svg | 9 ++ .../lib/connectors/resilient/translations.ts | 72 +++++++++++ .../common/lib/connectors/resilient/types.ts | 22 ++++ 8 files changed, 314 insertions(+) create mode 100644 x-pack/plugins/siem/public/common/lib/connectors/resilient/config.ts create mode 100644 x-pack/plugins/siem/public/common/lib/connectors/resilient/flyout.tsx create mode 100644 x-pack/plugins/siem/public/common/lib/connectors/resilient/index.tsx create mode 100644 x-pack/plugins/siem/public/common/lib/connectors/resilient/logo.svg create mode 100644 x-pack/plugins/siem/public/common/lib/connectors/resilient/translations.ts create mode 100644 x-pack/plugins/siem/public/common/lib/connectors/resilient/types.ts 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/siem/public/common/lib/connectors/resilient/config.ts b/x-pack/plugins/siem/public/common/lib/connectors/resilient/config.ts new file mode 100644 index 0000000000000..7d4edbf624877 --- /dev/null +++ b/x-pack/plugins/siem/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/siem/public/common/lib/connectors/resilient/flyout.tsx b/x-pack/plugins/siem/public/common/lib/connectors/resilient/flyout.tsx new file mode 100644 index 0000000000000..b0b591d8ea093 --- /dev/null +++ b/x-pack/plugins/siem/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/siem/public/common/lib/connectors/resilient/index.tsx b/x-pack/plugins/siem/public/common/lib/connectors/resilient/index.tsx new file mode 100644 index 0000000000000..d3daf195582a8 --- /dev/null +++ b/x-pack/plugins/siem/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/siem/public/common/lib/connectors/resilient/logo.svg b/x-pack/plugins/siem/public/common/lib/connectors/resilient/logo.svg new file mode 100644 index 0000000000000..8560cf7e270c8 --- /dev/null +++ b/x-pack/plugins/siem/public/common/lib/connectors/resilient/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/x-pack/plugins/siem/public/common/lib/connectors/resilient/translations.ts b/x-pack/plugins/siem/public/common/lib/connectors/resilient/translations.ts new file mode 100644 index 0000000000000..1b557483c61fa --- /dev/null +++ b/x-pack/plugins/siem/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.siem.case.connectors.resilient.selectMessageText', + { + defaultMessage: 'Push or update SIEM case data to a new issue in resilient', + } +); + +export const RESILIENT_TITLE = i18n.translate( + 'xpack.siem.case.connectors.resilient.actionTypeTitle', + { + defaultMessage: 'IBM Resilient', + } +); + +export const RESILIENT_PROJECT_KEY_LABEL = i18n.translate( + 'xpack.siem.case.connectors.resilient.orgId', + { + defaultMessage: 'Organization Id', + } +); + +export const RESILIENT_PROJECT_KEY_REQUIRED = i18n.translate( + 'xpack.siem.case.connectors.jira.requiredOrgIdTextField', + { + defaultMessage: 'Organization Id', + } +); + +export const RESILIENT_API_KEY_ID_LABEL = i18n.translate( + 'xpack.siem.case.connectors.resilient.apiKeyId', + { + defaultMessage: 'API key id', + } +); + +export const RESILIENT_API_KEY_ID_REQUIRED = i18n.translate( + 'xpack.siem.case.connectors.resilient.requiredApiKeyIdTextField', + { + defaultMessage: 'API key id is required', + } +); + +export const RESILIENT_API_KEY_SECRET_LABEL = i18n.translate( + 'xpack.siem.case.connectors.resilient.apiKeySecret', + { + defaultMessage: 'API key secret', + } +); + +export const RESILIENT_API_KEY_SECRET_REQUIRED = i18n.translate( + 'xpack.siem.case.connectors.resilient.requiredApiKeySecretTextField', + { + defaultMessage: 'API key secret is required', + } +); + +export const MAPPING_FIELD_NAME = i18n.translate( + 'xpack.siem.case.configureCases.mappingFieldName', + { + defaultMessage: 'Name', + } +); diff --git a/x-pack/plugins/siem/public/common/lib/connectors/resilient/types.ts b/x-pack/plugins/siem/public/common/lib/connectors/resilient/types.ts new file mode 100644 index 0000000000000..fe6dbb2b3674a --- /dev/null +++ b/x-pack/plugins/siem/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; +} From 6ba457277dc1ff1aaa92441b7b37a9d85f1e9eb4 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 13 May 2020 14:39:56 +0300 Subject: [PATCH 04/18] Register resilient --- x-pack/plugins/security_solution/public/plugin.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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); From 11402343a72fc2b3d49edb90699444fb9b4640bb Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 13 May 2020 14:50:05 +0300 Subject: [PATCH 05/18] Init connector --- .../server/builtin_action_types/index.ts | 2 + .../resilient/api.test.ts | 517 ++++++++++++++++++ .../builtin_action_types/resilient/api.ts | 7 + .../builtin_action_types/resilient/config.ts | 13 + .../builtin_action_types/resilient/index.ts | 24 + .../builtin_action_types/resilient/mocks.ts | 124 +++++ .../resilient/service.test.ts | 297 ++++++++++ .../builtin_action_types/resilient/service.ts | 156 ++++++ .../resilient/translations.ts | 11 + .../builtin_action_types/resilient/types.ts | 2 +- .../resilient/validators.ts | 13 + 11 files changed, 1165 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/api.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/api.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/index.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/translations.ts create mode 100644 x-pack/plugins/actions/server/builtin_action_types/resilient/validators.ts 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..bcfb82077d286 --- /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: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-04-27T10:59:46.202Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-04-27T10:59:46.202Z', + }, + ], + }); + }); + + 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: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-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-04-27T10:59:46.202Z by Elastic User)', + summary: 'Incident title (created at 2020-04-27T10:59:46.202Z 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: 'incident-1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + 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: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + comments: [ + { + commentId: 'case-comment-1', + pushedDate: '2020-04-27T10:59:46.202Z', + }, + { + commentId: 'case-comment-2', + pushedDate: '2020-04-27T10:59:46.202Z', + }, + ], + }); + }); + + test('it updates an incident without comments', async () => { + const params = { ...apiParams, comments: [] }; + const res = await api.pushToService({ externalService, mapping, params }); + + expect(res).toEqual({ + id: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-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-04-27T10:59:46.202Z by Elastic User)', + summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z 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: 'incident-1', + comment: { + commentId: 'case-comment-1', + comment: 'A comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + + expect(externalService.createComment).toHaveBeenNthCalledWith(2, { + incidentId: 'incident-1', + comment: { + commentId: 'case-comment-2', + comment: 'Another comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { + fullName: 'Elastic User', + username: 'elastic', + }, + }, + field: 'comments', + }); + }); + }); + + describe('mapping variations', () => { + test('overwrite & append', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + description: + 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('nothing & append', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'nothing', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + description: + 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('append & append', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'append', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: + 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + description: + 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('nothing & nothing', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + 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: 'summary', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('overwrite & overwrite', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'overwrite', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + description: + 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('nothing & overwrite', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'nothing', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + 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-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('append & overwrite', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'overwrite', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: + 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + description: + 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('append & nothing', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'append', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'append', + }); + + mapping.set('summary', { + target: 'title', + actionType: 'append', + }); + + await api.pushToService({ externalService, mapping, params: apiParams }); + expect(externalService.updateIncident).toHaveBeenCalledWith({ + incidentId: 'incident-3', + incident: { + summary: + 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + }, + }); + }); + + test('comment nothing', async () => { + mapping.set('title', { + target: 'summary', + actionType: 'overwrite', + }); + + mapping.set('description', { + target: 'description', + actionType: 'nothing', + }); + + mapping.set('comments', { + target: 'comments', + actionType: 'nothing', + }); + + mapping.set('summary', { + 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..a724bc8b38458 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/config.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 { ExternalServiceConfiguration } from '../case/types'; +import * as i18n from './translations'; + +export const config: ExternalServiceConfiguration = { + id: '.resilient', + name: i18n.NAME, +}; 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..3ae0e9db36de0 --- /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: 'incident-1', + key: 'CK-1', + summary: 'title from jira', + description: 'description from jira', + created: '2020-04-27T10:59:46.202Z', + updated: '2020-04-27T10:59:46.202Z', + }) + ), + createIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + }) + ), + updateIncident: jest.fn().mockImplementation(() => + Promise.resolve({ + id: 'incident-1', + title: 'CK-1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + }) + ), + createComment: jest.fn(), + }; + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-1', + pushedDate: '2020-04-27T10:59:46.202Z', + externalCommentId: '1', + }) + ); + + service.createComment.mockImplementationOnce(() => + Promise.resolve({ + commentId: 'case-comment-2', + pushedDate: '2020-04-27T10:59:46.202Z', + externalCommentId: '2', + }) + ); + + return service; +}; + +const externalServiceMock = { + create: createMock, +}; + +const mapping: Map> = new Map(); + +mapping.set('title', { + target: 'summary', + actionType: 'overwrite', +}); + +mapping.set('description', { + target: 'description', + actionType: 'overwrite', +}); + +mapping.set('comments', { + target: 'comments', + actionType: 'append', +}); + +mapping.set('summary', { + target: 'title', + actionType: 'overwrite', +}); + +const executorParams: ExecutorSubActionPushParams = { + caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + externalId: 'incident-3', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + title: 'Incident title', + description: 'Incident description', + comments: [ + { + commentId: 'case-comment-1', + comment: 'A comment', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + { + commentId: 'case-comment-2', + comment: 'Another comment', + createdAt: '2020-04-27T10:59:46.202Z', + createdBy: { fullName: 'Elastic User', username: 'elastic' }, + updatedAt: '2020-04-27T10:59:46.202Z', + updatedBy: { fullName: 'Elastic User', username: 'elastic' }, + }, + ], +}; + +const apiParams: PushToServiceApiParams = { + ...executorParams, + externalCase: { summary: 'Incident title', description: 'Incident description' }, +}; + +export { externalServiceMock, mapping, executorParams, apiParams }; 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..b9225b043d526 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts @@ -0,0 +1,297 @@ +/* + * 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 } from './service'; +import * as utils from '../case/utils'; +import { ExternalService } from '../case/types'; + +jest.mock('axios'); +jest.mock('../case/utils', () => { + const originalUtils = jest.requireActual('../case/utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +axios.create = jest.fn(() => axios); +const requestMock = utils.request as jest.Mock; + +describe('Jira service', () => { + let service: ExternalService; + + beforeAll(() => { + service = createExternalService({ + config: { apiUrl: 'https://siem-kibana.atlassian.net', projectKey: 'CK' }, + secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('createExternalService', () => { + test('throws without url', () => { + expect(() => + createExternalService({ + config: { apiUrl: null, projectKey: 'CK' }, + secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, + }) + ).toThrow(); + }); + + test('throws without projectKey', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com', projectKey: null }, + secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, + }) + ).toThrow(); + }); + + test('throws without username', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com' }, + secrets: { apiToken: '', email: 'elastic@elastic.com' }, + }) + ).toThrow(); + }); + + test('throws without password', () => { + expect(() => + createExternalService({ + config: { apiUrl: 'test.com' }, + secrets: { apiToken: '', email: undefined }, + }) + ).toThrow(); + }); + }); + + describe('getIncident', () => { + test('it returns the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, + })); + const res = await service.getIncident('1'); + expect(res).toEqual({ id: '1', key: 'CK-1', summary: 'title', description: 'description' }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { id: '1', key: 'CK-1' }, + })); + + await service.getIncident('1'); + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1', + }); + }); + + 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 () => { + // The response from Jira when creating an issue contains only the key and the id. + // The service makes two calls when creating an issue. One to create and one to get + // the created incident with all the necessary fields. + requestMock.mockImplementationOnce(() => ({ + data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, + })); + + requestMock.mockImplementationOnce(() => ({ + data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, + })); + + const res = await service.createIncident({ + incident: { summary: 'title', description: 'desc' }, + }); + + expect(res).toEqual({ + title: 'CK-1', + id: '1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + key: 'CK-1', + fields: { created: '2020-04-27T10:59:46.202Z' }, + }, + })); + + await service.createIncident({ + incident: { summary: 'title', description: 'desc' }, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + url: 'https://siem-kibana.atlassian.net/rest/api/2/issue', + method: 'post', + data: { + fields: { + summary: 'title', + description: 'desc', + project: { key: 'CK' }, + issuetype: { name: 'Task' }, + }, + }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.createIncident({ + incident: { summary: 'title', description: 'desc' }, + }) + ).rejects.toThrow('[Action][Jira]: Unable to create incident. Error: An error has occurred'); + }); + }); + + describe('updateIncident', () => { + test('it updates the incident correctly', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + key: 'CK-1', + fields: { updated: '2020-04-27T10:59:46.202Z' }, + }, + })); + + const res = await service.updateIncident({ + incidentId: '1', + incident: { summary: 'title', description: 'desc' }, + }); + + expect(res).toEqual({ + title: 'CK-1', + id: '1', + pushedDate: '2020-04-27T10:59:46.202Z', + url: 'https://siem-kibana.atlassian.net/browse/CK-1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + key: 'CK-1', + fields: { updated: '2020-04-27T10:59:46.202Z' }, + }, + })); + + await service.updateIncident({ + incidentId: '1', + incident: { summary: 'title', description: 'desc' }, + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + method: 'put', + url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1', + data: { fields: { summary: 'title', description: 'desc' } }, + }); + }); + + test('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + expect( + service.updateIncident({ + incidentId: '1', + incident: { summary: 'title', description: 'desc' }, + }) + ).rejects.toThrow( + '[Action][Jira]: 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', + key: 'CK-1', + created: '2020-04-27T10:59:46.202Z', + }, + })); + + const res = await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'comments', + }); + + expect(res).toEqual({ + commentId: 'comment-1', + pushedDate: '2020-04-27T10:59:46.202Z', + externalCommentId: '1', + }); + }); + + test('it should call request with correct arguments', async () => { + requestMock.mockImplementation(() => ({ + data: { + id: '1', + key: 'CK-1', + created: '2020-04-27T10:59:46.202Z', + }, + })); + + await service.createComment({ + incidentId: '1', + comment: { comment: 'comment', commentId: 'comment-1' }, + field: 'my_field', + }); + + expect(requestMock).toHaveBeenCalledWith({ + axios, + method: 'post', + url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1/comment', + data: { body: 'comment' }, + }); + }); + + 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][Jira]: 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..2a5d372e2e9c3 --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -0,0 +1,156 @@ +/* + * 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, +} from './types'; + +import * as i18n from './translations'; +import { getErrorMessage, request } from '../case/utils'; + +const VERSION = '2'; +const BASE_URL = `rest/api/${VERSION}`; +const INCIDENT_URL = `issue`; +const COMMENT_URL = `comment`; + +const VIEW_INCIDENT_URL = `browse`; + +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 incidentUrl = `${url}/${BASE_URL}/${INCIDENT_URL}`; + const commentUrl = `${incidentUrl}/{issueId}/${COMMENT_URL}`; + const axiosInstance = axios.create({ + auth: { username: apiKeyId, password: apiKeySecret }, + }); + + const getIncidentViewURL = (key: string) => { + return `${url}/${VIEW_INCIDENT_URL}/${key}`; + }; + + const getCommentsURL = (issueId: string) => { + return commentUrl.replace('{issueId}', issueId); + }; + + const getIncident = async (id: string) => { + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}/${id}`, + }); + + const { fields, ...rest } = res.data; + + return { ...rest, ...fields }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}`) + ); + } + }; + + const createIncident = async ({ incident }: ExternalServiceParams) => { + // The response from Resilient when creating an issue contains only the key and the id. + // The function makes two calls when creating an issue. One to create the issue and one to get + // the created issue with all the necessary fields. + try { + const res = await request({ + axios: axiosInstance, + url: `${incidentUrl}`, + method: 'post', + data: { + ...incident, + }, + }); + + const updatedIncident = await getIncident(res.data.id); + + return { + title: updatedIncident.key, + id: updatedIncident.id, + pushedDate: new Date(updatedIncident.created).toISOString(), + url: getIncidentViewURL(updatedIncident.key), + }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}`) + ); + } + }; + + const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { + try { + await request({ + axios: axiosInstance, + method: 'put', + url: `${incidentUrl}/${incidentId}`, + data: { ...incident }, + }); + + const updatedIncident = await getIncident(incidentId); + + return { + title: updatedIncident.key, + id: updatedIncident.id, + pushedDate: new Date(updatedIncident.updated).toISOString(), + url: getIncidentViewURL(updatedIncident.key), + }; + } 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.created).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 index 614619bbd2f9d..fc96fd6669200 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts @@ -25,5 +25,5 @@ interface CreateIncidentRequestArgs extends CreateIncidentBasicRequestArgs { } export type CreateIncidentRequest = CreateIncidentRequestArgs; - export type UpdateIncidentRequest = Partial; +export type CreateCommentRequest = Comment; 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, +}; From b98cc106ff26a142feba55beb83357327dea8cfb Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 13 May 2020 15:28:03 +0300 Subject: [PATCH 06/18] Fix urls --- .../builtin_action_types/resilient/service.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) 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 index 2a5d372e2e9c3..a7587fe28584c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -18,12 +18,11 @@ import { import * as i18n from './translations'; import { getErrorMessage, request } from '../case/utils'; -const VERSION = '2'; -const BASE_URL = `rest/api/${VERSION}`; -const INCIDENT_URL = `issue`; -const COMMENT_URL = `comment`; +const BASE_URL = `rest`; +const INCIDENT_URL = `incidents`; +const COMMENT_URL = `comments`; -const VIEW_INCIDENT_URL = `browse`; +const VIEW_INCIDENT_URL = `#incidents`; export const createExternalService = ({ config, @@ -36,8 +35,8 @@ export const createExternalService = ({ throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); } - const incidentUrl = `${url}/${BASE_URL}/${INCIDENT_URL}`; - const commentUrl = `${incidentUrl}/{issueId}/${COMMENT_URL}`; + const incidentUrl = `${url}/${BASE_URL}/${orgId}/${INCIDENT_URL}`; + const commentUrl = `${incidentUrl}/{inc_id}/${COMMENT_URL}`; const axiosInstance = axios.create({ auth: { username: apiKeyId, password: apiKeySecret }, }); @@ -46,8 +45,8 @@ export const createExternalService = ({ return `${url}/${VIEW_INCIDENT_URL}/${key}`; }; - const getCommentsURL = (issueId: string) => { - return commentUrl.replace('{issueId}', issueId); + const getCommentsURL = (incidentId: string) => { + return commentUrl.replace('{inc_id}', incidentId); }; const getIncident = async (id: string) => { From b87ed7d8360a82fda2c5c7741802440b645045a3 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 13 May 2020 15:28:29 +0300 Subject: [PATCH 07/18] Enable connector to API --- x-pack/plugins/case/common/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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']; From 6e248abdb25f2e6f21974ffec8778972e3ba9d0a Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 18 May 2020 11:47:57 +0300 Subject: [PATCH 08/18] Fix service --- .../resilient/api.test.ts | 150 +++++----- .../builtin_action_types/resilient/mocks.ts | 50 ++-- .../resilient/service.test.ts | 279 +++++++++++++----- .../builtin_action_types/resilient/service.ts | 86 ++++-- .../builtin_action_types/resilient/types.ts | 19 +- 5 files changed, 383 insertions(+), 201 deletions(-) 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 index bcfb82077d286..734f6be382629 100644 --- 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 @@ -26,18 +26,18 @@ describe('api', () => { const res = await api.pushToService({ externalService, mapping, params }); expect(res).toEqual({ - id: 'incident-1', - title: 'CK-1', - pushedDate: '2020-04-27T10:59:46.202Z', - url: 'https://siem-kibana.atlassian.net/browse/CK-1', + 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-04-27T10:59:46.202Z', + pushedDate: '2020-06-03T15:09:13.606Z', }, { commentId: 'case-comment-2', - pushedDate: '2020-04-27T10:59:46.202Z', + pushedDate: '2020-06-03T15:09:13.606Z', }, ], }); @@ -48,10 +48,10 @@ describe('api', () => { const res = await api.pushToService({ externalService, mapping, params }); expect(res).toEqual({ - id: 'incident-1', - title: 'CK-1', - pushedDate: '2020-04-27T10:59:46.202Z', - url: 'https://siem-kibana.atlassian.net/browse/CK-1', + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', }); }); @@ -62,8 +62,8 @@ describe('api', () => { expect(externalService.createIncident).toHaveBeenCalledWith({ incident: { description: - 'Incident description (created at 2020-04-27T10:59:46.202Z by Elastic User)', - summary: 'Incident title (created at 2020-04-27T10:59:46.202Z by Elastic User)', + '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(); @@ -74,16 +74,16 @@ describe('api', () => { await api.pushToService({ externalService, mapping, params }); expect(externalService.createComment).toHaveBeenCalledTimes(2); expect(externalService.createComment).toHaveBeenNthCalledWith(1, { - incidentId: 'incident-1', + incidentId: '1', comment: { commentId: 'case-comment-1', - comment: 'A comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', - createdAt: '2020-04-27T10:59:46.202Z', + 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-04-27T10:59:46.202Z', + updatedAt: '2020-06-03T15:09:13.606Z', updatedBy: { fullName: 'Elastic User', username: 'elastic', @@ -93,16 +93,16 @@ describe('api', () => { }); expect(externalService.createComment).toHaveBeenNthCalledWith(2, { - incidentId: 'incident-1', + incidentId: '1', comment: { commentId: 'case-comment-2', - comment: 'Another comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', - createdAt: '2020-04-27T10:59:46.202Z', + 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-04-27T10:59:46.202Z', + updatedAt: '2020-06-03T15:09:13.606Z', updatedBy: { fullName: 'Elastic User', username: 'elastic', @@ -118,18 +118,18 @@ describe('api', () => { const res = await api.pushToService({ externalService, mapping, params: apiParams }); expect(res).toEqual({ - id: 'incident-1', - title: 'CK-1', - pushedDate: '2020-04-27T10:59:46.202Z', - url: 'https://siem-kibana.atlassian.net/browse/CK-1', + 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-04-27T10:59:46.202Z', + pushedDate: '2020-06-03T15:09:13.606Z', }, { commentId: 'case-comment-2', - pushedDate: '2020-04-27T10:59:46.202Z', + pushedDate: '2020-06-03T15:09:13.606Z', }, ], }); @@ -140,10 +140,10 @@ describe('api', () => { const res = await api.pushToService({ externalService, mapping, params }); expect(res).toEqual({ - id: 'incident-1', - title: 'CK-1', - pushedDate: '2020-04-27T10:59:46.202Z', - url: 'https://siem-kibana.atlassian.net/browse/CK-1', + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', }); }); @@ -155,8 +155,8 @@ describe('api', () => { incidentId: 'incident-3', incident: { description: - 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', - summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + '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(); @@ -167,16 +167,16 @@ describe('api', () => { await api.pushToService({ externalService, mapping, params }); expect(externalService.createComment).toHaveBeenCalledTimes(2); expect(externalService.createComment).toHaveBeenNthCalledWith(1, { - incidentId: 'incident-1', + incidentId: '1', comment: { commentId: 'case-comment-1', - comment: 'A comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', - createdAt: '2020-04-27T10:59:46.202Z', + 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-04-27T10:59:46.202Z', + updatedAt: '2020-06-03T15:09:13.606Z', updatedBy: { fullName: 'Elastic User', username: 'elastic', @@ -186,16 +186,16 @@ describe('api', () => { }); expect(externalService.createComment).toHaveBeenNthCalledWith(2, { - incidentId: 'incident-1', + incidentId: '1', comment: { commentId: 'case-comment-2', - comment: 'Another comment (added at 2020-04-27T10:59:46.202Z by Elastic User)', - createdAt: '2020-04-27T10:59:46.202Z', + 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-04-27T10:59:46.202Z', + updatedAt: '2020-06-03T15:09:13.606Z', updatedBy: { fullName: 'Elastic User', username: 'elastic', @@ -209,7 +209,7 @@ describe('api', () => { describe('mapping variations', () => { test('overwrite & append', async () => { mapping.set('title', { - target: 'summary', + target: 'name', actionType: 'overwrite', }); @@ -223,7 +223,7 @@ describe('api', () => { actionType: 'append', }); - mapping.set('summary', { + mapping.set('name', { target: 'title', actionType: 'overwrite', }); @@ -232,16 +232,16 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { - summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', description: - 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + '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: 'summary', + target: 'name', actionType: 'nothing', }); @@ -255,7 +255,7 @@ describe('api', () => { actionType: 'append', }); - mapping.set('summary', { + mapping.set('name', { target: 'title', actionType: 'nothing', }); @@ -265,14 +265,14 @@ describe('api', () => { incidentId: 'incident-3', incident: { description: - 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + '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: 'summary', + target: 'name', actionType: 'append', }); @@ -286,7 +286,7 @@ describe('api', () => { actionType: 'append', }); - mapping.set('summary', { + mapping.set('name', { target: 'title', actionType: 'append', }); @@ -295,17 +295,17 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { - summary: - 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + name: + 'title from ibm resilient \r\nIncident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', description: - 'description from jira \r\nIncident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + '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: 'summary', + target: 'name', actionType: 'nothing', }); @@ -319,7 +319,7 @@ describe('api', () => { actionType: 'append', }); - mapping.set('summary', { + mapping.set('name', { target: 'title', actionType: 'nothing', }); @@ -333,7 +333,7 @@ describe('api', () => { test('overwrite & nothing', async () => { mapping.set('title', { - target: 'summary', + target: 'name', actionType: 'overwrite', }); @@ -347,7 +347,7 @@ describe('api', () => { actionType: 'append', }); - mapping.set('summary', { + mapping.set('name', { target: 'title', actionType: 'overwrite', }); @@ -356,14 +356,14 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { - summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', }, }); }); test('overwrite & overwrite', async () => { mapping.set('title', { - target: 'summary', + target: 'name', actionType: 'overwrite', }); @@ -377,7 +377,7 @@ describe('api', () => { actionType: 'append', }); - mapping.set('summary', { + mapping.set('name', { target: 'title', actionType: 'overwrite', }); @@ -386,16 +386,16 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { - summary: 'Incident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + name: 'Incident title (updated at 2020-06-03T15:09:13.606Z by Elastic User)', description: - 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', }, }); }); test('nothing & overwrite', async () => { mapping.set('title', { - target: 'summary', + target: 'name', actionType: 'nothing', }); @@ -409,7 +409,7 @@ describe('api', () => { actionType: 'append', }); - mapping.set('summary', { + mapping.set('name', { target: 'title', actionType: 'nothing', }); @@ -419,14 +419,14 @@ describe('api', () => { incidentId: 'incident-3', incident: { description: - 'Incident description (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', }, }); }); test('append & overwrite', async () => { mapping.set('title', { - target: 'summary', + target: 'name', actionType: 'append', }); @@ -440,7 +440,7 @@ describe('api', () => { actionType: 'append', }); - mapping.set('summary', { + mapping.set('name', { target: 'title', actionType: 'append', }); @@ -449,17 +449,17 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { - summary: - 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + 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-04-27T10:59:46.202Z by Elastic User)', + 'Incident description (updated at 2020-06-03T15:09:13.606Z by Elastic User)', }, }); }); test('append & nothing', async () => { mapping.set('title', { - target: 'summary', + target: 'name', actionType: 'append', }); @@ -473,7 +473,7 @@ describe('api', () => { actionType: 'append', }); - mapping.set('summary', { + mapping.set('name', { target: 'title', actionType: 'append', }); @@ -482,15 +482,15 @@ describe('api', () => { expect(externalService.updateIncident).toHaveBeenCalledWith({ incidentId: 'incident-3', incident: { - summary: - 'title from jira \r\nIncident title (updated at 2020-04-27T10:59:46.202Z by Elastic User)', + 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: 'summary', + target: 'name', actionType: 'overwrite', }); @@ -504,7 +504,7 @@ describe('api', () => { actionType: 'nothing', }); - mapping.set('summary', { + mapping.set('name', { target: 'title', actionType: 'overwrite', }); 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 index 3ae0e9db36de0..ffbe6de5b0191 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts @@ -15,28 +15,28 @@ const createMock = (): jest.Mocked => { const service = { getIncident: jest.fn().mockImplementation(() => Promise.resolve({ - id: 'incident-1', - key: 'CK-1', - summary: 'title from jira', - description: 'description from jira', - created: '2020-04-27T10:59:46.202Z', - updated: '2020-04-27T10:59:46.202Z', + 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: 'incident-1', - title: 'CK-1', - pushedDate: '2020-04-27T10:59:46.202Z', - url: 'https://siem-kibana.atlassian.net/browse/CK-1', + 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: 'incident-1', - title: 'CK-1', - pushedDate: '2020-04-27T10:59:46.202Z', - url: 'https://siem-kibana.atlassian.net/browse/CK-1', + id: '1', + title: '1', + pushedDate: '2020-06-03T15:09:13.606Z', + url: 'https://resilient.elastic.co/#incidents/1', }) ), createComment: jest.fn(), @@ -45,7 +45,7 @@ const createMock = (): jest.Mocked => { service.createComment.mockImplementationOnce(() => Promise.resolve({ commentId: 'case-comment-1', - pushedDate: '2020-04-27T10:59:46.202Z', + pushedDate: '2020-06-03T15:09:13.606Z', externalCommentId: '1', }) ); @@ -53,7 +53,7 @@ const createMock = (): jest.Mocked => { service.createComment.mockImplementationOnce(() => Promise.resolve({ commentId: 'case-comment-2', - pushedDate: '2020-04-27T10:59:46.202Z', + pushedDate: '2020-06-03T15:09:13.606Z', externalCommentId: '2', }) ); @@ -68,7 +68,7 @@ const externalServiceMock = { const mapping: Map> = new Map(); mapping.set('title', { - target: 'summary', + target: 'name', actionType: 'overwrite', }); @@ -82,7 +82,7 @@ mapping.set('comments', { actionType: 'append', }); -mapping.set('summary', { +mapping.set('name', { target: 'title', actionType: 'overwrite', }); @@ -90,9 +90,9 @@ mapping.set('summary', { const executorParams: ExecutorSubActionPushParams = { caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', externalId: 'incident-3', - createdAt: '2020-04-27T10:59:46.202Z', + createdAt: '2020-06-03T15:09:13.606Z', createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-04-27T10:59:46.202Z', + updatedAt: '2020-06-03T15:09:13.606Z', updatedBy: { fullName: 'Elastic User', username: 'elastic' }, title: 'Incident title', description: 'Incident description', @@ -100,17 +100,17 @@ const executorParams: ExecutorSubActionPushParams = { { commentId: 'case-comment-1', comment: 'A comment', - createdAt: '2020-04-27T10:59:46.202Z', + createdAt: '2020-06-03T15:09:13.606Z', createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-04-27T10:59:46.202Z', + updatedAt: '2020-06-03T15:09:13.606Z', updatedBy: { fullName: 'Elastic User', username: 'elastic' }, }, { commentId: 'case-comment-2', comment: 'Another comment', - createdAt: '2020-04-27T10:59:46.202Z', + createdAt: '2020-06-03T15:09:13.606Z', createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: '2020-04-27T10:59:46.202Z', + updatedAt: '2020-06-03T15:09:13.606Z', updatedBy: { fullName: 'Elastic User', username: 'elastic' }, }, ], @@ -118,7 +118,7 @@ const executorParams: ExecutorSubActionPushParams = { const apiParams: PushToServiceApiParams = { ...executorParams, - externalCase: { summary: 'Incident title', description: 'Incident description' }, + externalCase: { name: 'Incident title', description: 'Incident description' }, }; export { externalServiceMock, mapping, executorParams, apiParams }; 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 index b9225b043d526..295bd11aa0e52 100644 --- 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 @@ -6,7 +6,7 @@ import axios from 'axios'; -import { createExternalService } from './service'; +import { createExternalService, getValueTextContent, formatUpdateRequest } from './service'; import * as utils from '../case/utils'; import { ExternalService } from '../case/types'; @@ -21,36 +21,135 @@ jest.mock('../case/utils', () => { axios.create = jest.fn(() => axios); const requestMock = utils.request as jest.Mock; - -describe('Jira service', () => { +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://siem-kibana.atlassian.net', projectKey: 'CK' }, - secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, + config: { apiUrl: 'https://resilient.elastic.co', orgId: '201' }, + secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' }, }); }); + afterAll(() => { + Date.now = now; + }); + beforeEach(() => { - jest.clearAllMocks(); + 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, projectKey: 'CK' }, - secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, + config: { apiUrl: null, orgId: '201' }, + secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, }) ).toThrow(); }); - test('throws without projectKey', () => { + test('throws without orgId', () => { expect(() => createExternalService({ - config: { apiUrl: 'test.com', projectKey: null }, - secrets: { apiToken: 'token', email: 'elastic@elastic.com' }, + config: { apiUrl: 'test.com', orgId: null }, + secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, }) ).toThrow(); }); @@ -59,7 +158,7 @@ describe('Jira service', () => { expect(() => createExternalService({ config: { apiUrl: 'test.com' }, - secrets: { apiToken: '', email: 'elastic@elastic.com' }, + secrets: { apiKeyId: '', apiKeySecret: 'secret' }, }) ).toThrow(); }); @@ -68,7 +167,7 @@ describe('Jira service', () => { expect(() => createExternalService({ config: { apiUrl: 'test.com' }, - secrets: { apiToken: '', email: undefined }, + secrets: { apiKeyId: '', apiKeySecret: undefined }, }) ).toThrow(); }); @@ -77,21 +176,29 @@ describe('Jira service', () => { describe('getIncident', () => { test('it returns the incident correctly', async () => { requestMock.mockImplementation(() => ({ - data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, + data: { + id: '1', + name: '1', + description: { + format: 'html', + content: 'description', + }, + }, })); const res = await service.getIncident('1'); - expect(res).toEqual({ id: '1', key: 'CK-1', summary: 'title', description: 'description' }); + expect(res).toEqual({ id: '1', name: '1', description: 'description' }); }); test('it should call request with correct arguments', async () => { requestMock.mockImplementation(() => ({ - data: { id: '1', key: 'CK-1' }, + data: { id: '1' }, })); await service.getIncident('1'); expect(requestMock).toHaveBeenCalledWith({ axios, - url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1', + url: + 'https://resilient.elastic.co/rest/orgs/201/incidents/1?text_content_output_format=objects_convert', }); }); @@ -107,26 +214,25 @@ describe('Jira service', () => { describe('createIncident', () => { test('it creates the incident correctly', async () => { - // The response from Jira when creating an issue contains only the key and the id. - // The service makes two calls when creating an issue. One to create and one to get - // the created incident with all the necessary fields. - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { summary: 'title', description: 'description' } }, - })); - - requestMock.mockImplementationOnce(() => ({ - data: { id: '1', key: 'CK-1', fields: { created: '2020-04-27T10:59:46.202Z' } }, + requestMock.mockImplementation(() => ({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, })); const res = await service.createIncident({ - incident: { summary: 'title', description: 'desc' }, + incident: { name: 'title', description: 'desc' }, }); expect(res).toEqual({ - title: 'CK-1', + title: '1', id: '1', - pushedDate: '2020-04-27T10:59:46.202Z', - url: 'https://siem-kibana.atlassian.net/browse/CK-1', + pushedDate: '2020-05-13T17:44:34.472Z', + url: 'https://resilient.elastic.co/#incidents/1', }); }); @@ -134,26 +240,28 @@ describe('Jira service', () => { requestMock.mockImplementation(() => ({ data: { id: '1', - key: 'CK-1', - fields: { created: '2020-04-27T10:59:46.202Z' }, + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, }, })); await service.createIncident({ - incident: { summary: 'title', description: 'desc' }, + incident: { name: 'title', description: 'desc' }, }); expect(requestMock).toHaveBeenCalledWith({ axios, - url: 'https://siem-kibana.atlassian.net/rest/api/2/issue', + url: 'https://resilient.elastic.co/rest/orgs/201/incidents', method: 'post', data: { - fields: { - summary: 'title', - description: 'desc', - project: { key: 'CK' }, - issuetype: { name: 'Task' }, + name: 'title', + description: { + format: 'html', + content: 'desc', }, + discovered_date: TIMESTAMP, }, }); }); @@ -165,69 +273,81 @@ describe('Jira service', () => { expect( service.createIncident({ - incident: { summary: 'title', description: 'desc' }, + incident: { name: 'title', description: 'desc' }, }) - ).rejects.toThrow('[Action][Jira]: Unable to create incident. Error: An error has occurred'); + ).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred' + ); }); }); describe('updateIncident', () => { test('it updates the incident correctly', async () => { - requestMock.mockImplementation(() => ({ - data: { - id: '1', - key: 'CK-1', - fields: { updated: '2020-04-27T10:59:46.202Z' }, - }, - })); - + mockIncidentUpdate(); const res = await service.updateIncident({ incidentId: '1', - incident: { summary: 'title', description: 'desc' }, + incident: { name: 'title_updated', description: 'desc_updated' }, }); expect(res).toEqual({ - title: 'CK-1', + title: '1', id: '1', - pushedDate: '2020-04-27T10:59:46.202Z', - url: 'https://siem-kibana.atlassian.net/browse/CK-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', - key: 'CK-1', - fields: { updated: '2020-04-27T10:59:46.202Z' }, - }, - })); + mockIncidentUpdate(); await service.updateIncident({ incidentId: '1', - incident: { summary: 'title', description: 'desc' }, + incident: { name: 'title_updated', description: 'desc_updated' }, }); - expect(requestMock).toHaveBeenCalledWith({ + // 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: 'put', - url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1', - data: { fields: { summary: 'title', description: 'desc' } }, + 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 () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); - }); + mockIncidentUpdate(true); expect( service.updateIncident({ incidentId: '1', - incident: { summary: 'title', description: 'desc' }, + incident: { name: 'title', description: 'desc' }, }) ).rejects.toThrow( - '[Action][Jira]: Unable to update incident with id 1. Error: An error has occurred' + '[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred' ); }); }); @@ -237,8 +357,7 @@ describe('Jira service', () => { requestMock.mockImplementation(() => ({ data: { id: '1', - key: 'CK-1', - created: '2020-04-27T10:59:46.202Z', + create_date: 1589391874472, }, })); @@ -250,7 +369,7 @@ describe('Jira service', () => { expect(res).toEqual({ commentId: 'comment-1', - pushedDate: '2020-04-27T10:59:46.202Z', + pushedDate: '2020-05-13T17:44:34.472Z', externalCommentId: '1', }); }); @@ -259,8 +378,7 @@ describe('Jira service', () => { requestMock.mockImplementation(() => ({ data: { id: '1', - key: 'CK-1', - created: '2020-04-27T10:59:46.202Z', + create_date: 1589391874472, }, })); @@ -273,8 +391,13 @@ describe('Jira service', () => { expect(requestMock).toHaveBeenCalledWith({ axios, method: 'post', - url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1/comment', - data: { body: 'comment' }, + url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1/comments', + data: { + text: { + content: 'comment', + format: 'text', + }, + }, }); }); @@ -290,7 +413,7 @@ describe('Jira service', () => { field: 'comments', }) ).rejects.toThrow( - '[Action][Jira]: Unable to create comment at incident with id 1. Error: An error has occurred' + '[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 index a7587fe28584c..c75b8e222e934 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -5,6 +5,7 @@ */ import axios from 'axios'; +import https from 'https'; import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types'; import { @@ -13,6 +14,8 @@ import { CreateIncidentRequest, UpdateIncidentRequest, CreateCommentRequest, + UpdateFieldText, + UpdateFieldTextArea, } from './types'; import * as i18n from './translations'; @@ -24,6 +27,37 @@ 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, @@ -35,9 +69,12 @@ export const createExternalService = ({ throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); } - const incidentUrl = `${url}/${BASE_URL}/${orgId}/${INCIDENT_URL}`; + const incidentUrl = `${url}/${BASE_URL}/orgs/${orgId}/${INCIDENT_URL}`; const commentUrl = `${incidentUrl}/{inc_id}/${COMMENT_URL}`; const axiosInstance = axios.create({ + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), auth: { username: apiKeyId, password: apiKeySecret }, }); @@ -53,12 +90,10 @@ export const createExternalService = ({ try { const res = await request({ axios: axiosInstance, - url: `${incidentUrl}/${id}`, + url: `${incidentUrl}/${id}?text_content_output_format=objects_convert`, }); - const { fields, ...rest } = res.data; - - return { ...rest, ...fields }; + 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}`) @@ -67,9 +102,6 @@ export const createExternalService = ({ }; const createIncident = async ({ incident }: ExternalServiceParams) => { - // The response from Resilient when creating an issue contains only the key and the id. - // The function makes two calls when creating an issue. One to create the issue and one to get - // the created issue with all the necessary fields. try { const res = await request({ axios: axiosInstance, @@ -77,16 +109,19 @@ export const createExternalService = ({ method: 'post', data: { ...incident, + description: { + format: 'html', + content: incident.description ?? '', + }, + discovered_date: Date.now(), }, }); - const updatedIncident = await getIncident(res.data.id); - return { - title: updatedIncident.key, - id: updatedIncident.id, - pushedDate: new Date(updatedIncident.created).toISOString(), - url: getIncidentViewURL(updatedIncident.key), + 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( @@ -97,20 +132,27 @@ export const createExternalService = ({ const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => { try { - await request({ + const latestIncident = await getIncident(incidentId); + + const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident: incident }); + const res = await request({ axios: axiosInstance, - method: 'put', + method: 'patch', url: `${incidentUrl}/${incidentId}`, - data: { ...incident }, + data, }); + if (!res.data.success) { + throw new Error(res.data.message); + } + const updatedIncident = await getIncident(incidentId); return { - title: updatedIncident.key, - id: updatedIncident.id, - pushedDate: new Date(updatedIncident.updated).toISOString(), - url: getIncidentViewURL(updatedIncident.key), + title: `${updatedIncident.id}`, + id: `${updatedIncident.id}`, + pushedDate: new Date(updatedIncident.inc_last_modified_date).toISOString(), + url: getIncidentViewURL(updatedIncident.id), }; } catch (error) { throw new Error( @@ -134,7 +176,7 @@ export const createExternalService = ({ return { commentId: comment.commentId, externalCommentId: res.data.id, - pushedDate: new Date(res.data.created).toISOString(), + pushedDate: new Date(res.data.create_date).toISOString(), }; } catch (error) { throw new Error( 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 index fc96fd6669200..6869e2ff3a105 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/types.ts @@ -24,6 +24,23 @@ 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 UpdateIncidentRequest = Partial; export type CreateCommentRequest = Comment; + +export interface UpdateIncidentRequest { + changes: UpdateField[]; +} From f79d96fcfdeebdcfd80de7ef6b6d0fd2017b1833 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 15 Jun 2020 13:05:42 +0300 Subject: [PATCH 09/18] Fix rebase --- .../actions/server/builtin_action_types/resilient/config.ts | 1 + .../public/common/lib/connectors/resilient/config.ts | 0 .../public/common/lib/connectors/resilient/flyout.tsx | 6 +++--- .../public/common/lib/connectors/resilient/index.tsx | 0 .../public/common/lib/connectors/resilient/logo.svg | 0 .../public/common/lib/connectors/resilient/translations.ts | 0 .../public/common/lib/connectors/resilient/types.ts | 0 7 files changed, 4 insertions(+), 3 deletions(-) rename x-pack/plugins/{siem => security_solution}/public/common/lib/connectors/resilient/config.ts (100%) rename x-pack/plugins/{siem => security_solution}/public/common/lib/connectors/resilient/flyout.tsx (94%) rename x-pack/plugins/{siem => security_solution}/public/common/lib/connectors/resilient/index.tsx (100%) rename x-pack/plugins/{siem => security_solution}/public/common/lib/connectors/resilient/logo.svg (100%) rename x-pack/plugins/{siem => security_solution}/public/common/lib/connectors/resilient/translations.ts (100%) rename x-pack/plugins/{siem => security_solution}/public/common/lib/connectors/resilient/types.ts (100%) 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 index a724bc8b38458..4ce9417bfa9a1 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/config.ts @@ -10,4 +10,5 @@ import * as i18n from './translations'; export const config: ExternalServiceConfiguration = { id: '.resilient', name: i18n.NAME, + minimumLicenseRequired: 'platinum', }; diff --git a/x-pack/plugins/siem/public/common/lib/connectors/resilient/config.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts similarity index 100% rename from x-pack/plugins/siem/public/common/lib/connectors/resilient/config.ts rename to x-pack/plugins/security_solution/public/common/lib/connectors/resilient/config.ts diff --git a/x-pack/plugins/siem/public/common/lib/connectors/resilient/flyout.tsx b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx similarity index 94% rename from x-pack/plugins/siem/public/common/lib/connectors/resilient/flyout.tsx rename to x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx index b0b591d8ea093..31bf0a4dfc34b 100644 --- a/x-pack/plugins/siem/public/common/lib/connectors/resilient/flyout.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/flyout.tsx @@ -49,7 +49,7 @@ const resilientConnectorForm: React.FC onChangeConfig('orgId', evt.target.value)} + onChange={(evt) => onChangeConfig('orgId', evt.target.value)} onBlur={() => onBlurConfig('orgId')} /> @@ -71,7 +71,7 @@ const resilientConnectorForm: React.FC onChangeSecret('apiKeyId', evt.target.value)} + onChange={(evt) => onChangeSecret('apiKeyId', evt.target.value)} onBlur={() => onBlurSecret('apiKeyId')} /> @@ -93,7 +93,7 @@ const resilientConnectorForm: React.FC onChangeSecret('apiKeySecret', evt.target.value)} + onChange={(evt) => onChangeSecret('apiKeySecret', evt.target.value)} onBlur={() => onBlurSecret('apiKeySecret')} /> diff --git a/x-pack/plugins/siem/public/common/lib/connectors/resilient/index.tsx b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx similarity index 100% rename from x-pack/plugins/siem/public/common/lib/connectors/resilient/index.tsx rename to x-pack/plugins/security_solution/public/common/lib/connectors/resilient/index.tsx diff --git a/x-pack/plugins/siem/public/common/lib/connectors/resilient/logo.svg b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg similarity index 100% rename from x-pack/plugins/siem/public/common/lib/connectors/resilient/logo.svg rename to x-pack/plugins/security_solution/public/common/lib/connectors/resilient/logo.svg diff --git a/x-pack/plugins/siem/public/common/lib/connectors/resilient/translations.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts similarity index 100% rename from x-pack/plugins/siem/public/common/lib/connectors/resilient/translations.ts rename to x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts diff --git a/x-pack/plugins/siem/public/common/lib/connectors/resilient/types.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts similarity index 100% rename from x-pack/plugins/siem/public/common/lib/connectors/resilient/types.ts rename to x-pack/plugins/security_solution/public/common/lib/connectors/resilient/types.ts From 3da28d7041b890cc53482e92a52b9a4bd8382bb3 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 15 Jun 2020 15:00:04 +0300 Subject: [PATCH 10/18] Add integration tests --- .../components/configure_cases/index.test.tsx | 3 + .../lib/connectors/resilient/translations.ts | 18 +- .../alerting_api_integration/common/config.ts | 1 + .../actions_simulators/server/plugin.ts | 4 + .../server/resilient_simulation.ts | 111 ++++ .../actions/builtin_action_types/resilient.ts | 549 ++++++++++++++++++ .../tests/actions/index.ts | 1 + 7 files changed, 678 insertions(+), 9 deletions(-) create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/resilient_simulation.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/resilient.ts 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/resilient/translations.ts b/x-pack/plugins/security_solution/public/common/lib/connectors/resilient/translations.ts index 1b557483c61fa..f8aec2eea3d4b 100644 --- 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 @@ -9,63 +9,63 @@ import { i18n } from '@kbn/i18n'; export * from '../translations'; export const RESILIENT_DESC = i18n.translate( - 'xpack.siem.case.connectors.resilient.selectMessageText', + '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.siem.case.connectors.resilient.actionTypeTitle', + 'xpack.securitySolution.case.connectors.resilient.actionTypeTitle', { defaultMessage: 'IBM Resilient', } ); export const RESILIENT_PROJECT_KEY_LABEL = i18n.translate( - 'xpack.siem.case.connectors.resilient.orgId', + 'xpack.securitySolution.case.connectors.resilient.orgId', { defaultMessage: 'Organization Id', } ); export const RESILIENT_PROJECT_KEY_REQUIRED = i18n.translate( - 'xpack.siem.case.connectors.jira.requiredOrgIdTextField', + 'xpack.securitySolution.case.connectors.resilient.requiredOrgIdTextField', { defaultMessage: 'Organization Id', } ); export const RESILIENT_API_KEY_ID_LABEL = i18n.translate( - 'xpack.siem.case.connectors.resilient.apiKeyId', + 'xpack.securitySolution.case.connectors.resilient.apiKeyId', { defaultMessage: 'API key id', } ); export const RESILIENT_API_KEY_ID_REQUIRED = i18n.translate( - 'xpack.siem.case.connectors.resilient.requiredApiKeyIdTextField', + 'xpack.securitySolution.case.connectors.resilient.requiredApiKeyIdTextField', { defaultMessage: 'API key id is required', } ); export const RESILIENT_API_KEY_SECRET_LABEL = i18n.translate( - 'xpack.siem.case.connectors.resilient.apiKeySecret', + 'xpack.securitySolution.case.connectors.resilient.apiKeySecret', { defaultMessage: 'API key secret', } ); export const RESILIENT_API_KEY_SECRET_REQUIRED = i18n.translate( - 'xpack.siem.case.connectors.resilient.requiredApiKeySecretTextField', + 'xpack.securitySolution.case.connectors.resilient.requiredApiKeySecretTextField', { defaultMessage: 'API key secret is required', } ); export const MAPPING_FIELD_NAME = i18n.translate( - 'xpack.siem.case.configureCases.mappingFieldName', + 'xpack.securitySolution.case.configureCases.mappingFieldName', { defaultMessage: 'Name', } 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..19515f1d3e230 --- /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 jiraTest({ 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: { + caseId: '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.caseId]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle failing with a simulated success without caseId', 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.caseId]: 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: { + caseId: '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: { + caseId: '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, + caseId: '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, + caseId: '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, + caseId: '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')); From 20a7ce64f00eab00edaebd271291b2a013def082 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 2 Jul 2020 14:49:12 +0300 Subject: [PATCH 11/18] Review improvements --- .../actions/server/builtin_action_types/case/utils.test.ts | 2 +- .../server/builtin_action_types/resilient/service.test.ts | 6 ++++-- .../server/builtin_action_types/resilient/service.ts | 5 ++++- 3 files changed, 9 insertions(+), 4 deletions(-) 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/resilient/service.test.ts b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.test.ts index 295bd11aa0e52..f92d12042c5d8 100644 --- 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 @@ -197,8 +197,10 @@ describe('IBM Resilient service', () => { await service.getIncident('1'); expect(requestMock).toHaveBeenCalledWith({ axios, - url: - 'https://resilient.elastic.co/rest/orgs/201/incidents/1?text_content_output_format=objects_convert', + url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1', + params: { + text_content_output_format: 'objects_convert', + }, }); }); 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 index c75b8e222e934..ca1a11b8f12b6 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -90,7 +90,10 @@ export const createExternalService = ({ try { const res = await request({ axios: axiosInstance, - url: `${incidentUrl}/${id}?text_content_output_format=objects_convert`, + url: `${incidentUrl}/${id}`, + params: { + text_content_output_format: 'objects_convert', + }, }); return { ...res.data, description: res.data.description?.content ?? '' }; From 5643bdcd9eba394e81a1263ea01c90ee5a0c6891 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 2 Jul 2020 15:26:06 +0300 Subject: [PATCH 12/18] Reject unauthorized connections --- .../actions/server/builtin_action_types/resilient/service.ts | 4 ---- 1 file changed, 4 deletions(-) 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 index ca1a11b8f12b6..6ea3b265ab62f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -5,7 +5,6 @@ */ import axios from 'axios'; -import https from 'https'; import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types'; import { @@ -72,9 +71,6 @@ export const createExternalService = ({ const incidentUrl = `${url}/${BASE_URL}/orgs/${orgId}/${INCIDENT_URL}`; const commentUrl = `${incidentUrl}/{inc_id}/${COMMENT_URL}`; const axiosInstance = axios.create({ - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), auth: { username: apiKeyId, password: apiKeySecret }, }); From 011f40f098bb2c30bdab944fb41d25c9d281a246 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 2 Jul 2020 17:19:57 +0300 Subject: [PATCH 13/18] Add resilient logo --- .../public/common/lib/connectors/resilient/logo.svg | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) 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 index 8560cf7e270c8..7c50d897fc6b9 100644 --- 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 @@ -1,9 +1,3 @@ - - - - - - - - + + From dcc3a7a93240c73564616693a91a9c0f1e399eee Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 3 Jul 2020 16:39:32 +0300 Subject: [PATCH 14/18] Add documentation --- x-pack/plugins/actions/README.md | 79 +++++++++++++++++++++++++------- 1 file changed, 62 insertions(+), 17 deletions(-) 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: From dce6034e7084e67e6ea03de94d58cbac4842010a Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 7 Jul 2020 14:43:02 +0300 Subject: [PATCH 15/18] Fix types --- .../builtin_action_types/resilient/mocks.ts | 2 +- .../resilient/service.test.ts | 6 +++--- .../builtin_action_types/resilient/service.ts | 2 +- .../actions/builtin_action_types/resilient.ts | 18 +++++++++--------- 4 files changed, 14 insertions(+), 14 deletions(-) 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 index ffbe6de5b0191..bba9c58bf28c9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/mocks.ts @@ -88,7 +88,7 @@ mapping.set('name', { }); const executorParams: ExecutorSubActionPushParams = { - caseId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', + savedObjectId: 'd4387ac5-0899-4dc2-bbfa-0dd605c934aa', externalId: 'incident-3', createdAt: '2020-06-03T15:09:13.606Z', createdBy: { fullName: 'Elastic User', username: 'elastic' }, 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 index f92d12042c5d8..bfefa7fc9ca9b 100644 --- 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 @@ -7,12 +7,12 @@ import axios from 'axios'; import { createExternalService, getValueTextContent, formatUpdateRequest } from './service'; -import * as utils from '../case/utils'; +import * as utils from '../lib/axios_utils'; import { ExternalService } from '../case/types'; jest.mock('axios'); -jest.mock('../case/utils', () => { - const originalUtils = jest.requireActual('../case/utils'); +jest.mock('../lib/axios_utils', () => { + const originalUtils = jest.requireActual('../lib/axios_utils'); return { ...originalUtils, request: jest.fn(), 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 index 6ea3b265ab62f..e02537561a025 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -18,7 +18,7 @@ import { } from './types'; import * as i18n from './translations'; -import { getErrorMessage, request } from '../case/utils'; +import { getErrorMessage, request } from '../lib/axios_utils'; const BASE_URL = `rest`; const INCIDENT_URL = `incidents`; 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 index 19515f1d3e230..dcd0aba280abd 100644 --- 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 @@ -50,7 +50,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { params: { subAction: 'pushToService', subActionParams: { - caseId: '123', + savedObjectId: '123', title: 'a title', description: 'a description', createdAt: '2020-03-13T08:34:53.450Z', @@ -361,12 +361,12 @@ export default function jiraTest({ getService }: FtrProviderContext) { 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.caseId]: expected value of type [string] but got [undefined]', + '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 caseId', async () => { + it('should handle failing with a simulated success without savedObjectId', async () => { await supertest .post(`/api/actions/action/${simulatedActionId}/_execute`) .set('kbn-xsrf', 'foo') @@ -379,7 +379,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { 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.caseId]: expected value of type [string] but got [undefined]', + '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]', }); }); }); @@ -392,7 +392,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { params: { ...mockResilient.params, subActionParams: { - caseId: 'success', + savedObjectId: 'success', }, }, }) @@ -415,7 +415,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { params: { ...mockResilient.params, subActionParams: { - caseId: 'success', + savedObjectId: 'success', title: 'success', }, }, @@ -440,7 +440,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { ...mockResilient.params, subActionParams: { ...mockResilient.params.subActionParams, - caseId: 'success', + savedObjectId: 'success', title: 'success', createdAt: 'success', createdBy: { username: 'elastic' }, @@ -468,7 +468,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { ...mockResilient.params, subActionParams: { ...mockResilient.params.subActionParams, - caseId: 'success', + savedObjectId: 'success', title: 'success', createdAt: 'success', createdBy: { username: 'elastic' }, @@ -496,7 +496,7 @@ export default function jiraTest({ getService }: FtrProviderContext) { ...mockResilient.params, subActionParams: { ...mockResilient.params.subActionParams, - caseId: 'success', + savedObjectId: 'success', title: 'success', createdAt: 'success', createdBy: { username: 'elastic' }, From 1d409ce0e2ecbd38616fa4ad27958f646bc5d4f6 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 9 Jul 2020 18:44:34 +0300 Subject: [PATCH 16/18] Rename test --- .../tests/actions/builtin_action_types/resilient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index dcd0aba280abd..a77e0414a19d4 100644 --- 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 @@ -32,7 +32,7 @@ const mapping = [ ]; // eslint-disable-next-line import/no-default-export -export default function jiraTest({ getService }: FtrProviderContext) { +export default function resilientTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); From 2032a0e4af92190afb1ee00cf6bfebe2d6bfa4f8 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 9 Jul 2020 18:48:16 +0300 Subject: [PATCH 17/18] Fix test --- .../server/builtin_action_types/resilient/service.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index bfefa7fc9ca9b..573885698014e 100644 --- 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 @@ -157,7 +157,7 @@ describe('IBM Resilient service', () => { test('throws without username', () => { expect(() => createExternalService({ - config: { apiUrl: 'test.com' }, + config: { apiUrl: 'test.com', orgId: '201' }, secrets: { apiKeyId: '', apiKeySecret: 'secret' }, }) ).toThrow(); @@ -166,7 +166,7 @@ describe('IBM Resilient service', () => { test('throws without password', () => { expect(() => createExternalService({ - config: { apiUrl: 'test.com' }, + config: { apiUrl: 'test.com', orgId: '201' }, secrets: { apiKeyId: '', apiKeySecret: undefined }, }) ).toThrow(); From ec843662ca499ef4ed0d91c7b1a76305e1f5b7a1 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 9 Jul 2020 20:00:33 +0300 Subject: [PATCH 18/18] Fix trailing slash bug & logo --- .../actions/server/builtin_action_types/resilient/service.ts | 5 +++-- .../public/common/lib/connectors/resilient/logo.svg | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) 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 index e02537561a025..8d0526ca3b571 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/resilient/service.ts @@ -68,14 +68,15 @@ export const createExternalService = ({ throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); } - const incidentUrl = `${url}/${BASE_URL}/orgs/${orgId}/${INCIDENT_URL}`; + 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 `${url}/${VIEW_INCIDENT_URL}/${key}`; + return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}/${key}`; }; const getCommentsURL = (incidentId: string) => { 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 index 7c50d897fc6b9..553c2c62b7191 100644 --- 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 @@ -1,3 +1,3 @@ - - + +