[Connectors] ServiceNow ITSM & SIR Application (#105440)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2021-10-12 20:58:45 +03:00 committed by GitHub
parent 396ed09259
commit 7ffebf1fa3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
129 changed files with 5611 additions and 1312 deletions

View file

@ -362,7 +362,8 @@ The plugin exposes the static DefaultEditorController class to consume.
|{kib-repo}blob/{branch}/x-pack/plugins/cases/README.md[cases]
|Case management in Kibana
|[![Issues][issues-shield]][issues-url]
[![Pull Requests][pr-shield]][pr-url]
|{kib-repo}blob/{branch}/x-pack/plugins/cloud/README.md[cloud]

View file

@ -35,10 +35,14 @@ a| <<server-log-action-type, ServerLog>>
| Add a message to a Kibana log.
a| <<servicenow-action-type, ServiceNow>>
a| <<servicenow-action-type, ServiceNow ITSM>>
| Create an incident in ServiceNow.
a| <<servicenow-sir-action-type, ServiceNow SecOps>>
| Create a security incident in ServiceNow.
a| <<slack-action-type, Slack>>
| Send a message to a Slack channel or user.

View file

@ -0,0 +1,89 @@
[role="xpack"]
[[servicenow-sir-action-type]]
=== ServiceNow connector and action
++++
<titleabbrev>ServiceNow SecOps</titleabbrev>
++++
The ServiceNow SecOps connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow security incidents.
[float]
[[servicenow-sir-connector-configuration]]
==== Connector configuration
ServiceNow SecOps connectors have the following configuration properties.
Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action.
URL:: ServiceNow instance URL.
Username:: Username for HTTP Basic authentication.
Password:: Password for HTTP Basic authentication.
The ServiceNow user requires at minimum read, create, and update access to the Security Incident table and read access to the https://docs.servicenow.com/bundle/paris-platform-administration/page/administer/localization/reference/r_ChoicesTable.html[sys_choice]. If you don't provide access to sys_choice, then the choices will not render.
[float]
[[servicenow-sir-connector-networking-configuration]]
==== Connector networking configuration
Use the <<action-settings, Action configuration settings>> to customize connector networking configurations, such as proxies, certificates, or TLS settings. You can set configurations that apply to all your connectors or use `xpack.actions.customHostSettings` to set per-host configurations.
[float]
[[Preconfigured-servicenow-sir-configuration]]
==== Preconfigured connector type
[source,text]
--
my-servicenow-sir:
name: preconfigured-servicenow-connector-type
actionTypeId: .servicenow-sir
config:
apiUrl: https://dev94428.service-now.com/
secrets:
username: testuser
password: passwordkeystorevalue
--
Config defines information for the connector type.
`apiUrl`:: An address that corresponds to *URL*.
Secrets defines sensitive information for the connector type.
`username`:: A string that corresponds to *Username*.
`password`:: A string that corresponds to *Password*. Should be stored in the <<creating-keystore, {kib} keystore>>.
[float]
[[define-servicenow-sir-ui]]
==== Define connector in Stack Management
Define ServiceNow SecOps connector properties.
[role="screenshot"]
image::management/connectors/images/servicenow-sir-connector.png[ServiceNow SecOps connector]
Test ServiceNow SecOps action parameters.
[role="screenshot"]
image::management/connectors/images/servicenow-sir-params-test.png[ServiceNow SecOps params test]
[float]
[[servicenow-sir-action-configuration]]
==== Action configuration
ServiceNow SecOps actions have the following configuration properties.
Short description:: A short description for the incident, used for searching the contents of the knowledge base.
Source Ips:: A list of source IPs related to the incident. The IPs will be added as observables to the security incident.
Destination Ips:: A list of destination IPs related to the incident. The IPs will be added as observables to the security incident.
Malware URLs:: A list of malware URLs related to the incident. The URLs will be added as observables to the security incident.
Malware Hashes:: A list of malware hashes related to the incident. The hashes will be added as observables to the security incident.
Priority:: The priority of the incident.
Category:: The category of the incident.
Subcategory:: The subcategory of the incident.
Description:: The details about the incident.
Additional comments:: Additional information for the client, such as how to troubleshoot the issue.
[float]
[[configuring-servicenow-sir]]
==== Configure ServiceNow SecOps
ServiceNow offers free https://developer.servicenow.com/dev.do#!/guides/madrid/now-platform/pdi-guide/obtaining-a-pdi[Personal Developer Instances], which you can use to test incidents.

View file

@ -2,16 +2,16 @@
[[servicenow-action-type]]
=== ServiceNow connector and action
++++
<titleabbrev>ServiceNow</titleabbrev>
<titleabbrev>ServiceNow ITSM</titleabbrev>
++++
The ServiceNow connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents.
The ServiceNow ITSM connector uses the https://docs.servicenow.com/bundle/orlando-application-development/page/integrate/inbound-rest/concept/c_TableAPI.html[V2 Table API] to create ServiceNow incidents.
[float]
[[servicenow-connector-configuration]]
==== Connector configuration
ServiceNow connectors have the following configuration properties.
ServiceNow ITSM connectors have the following configuration properties.
Name:: The name of the connector. The name is used to identify a connector in the **Stack Management** UI connector listing, and in the connector list when configuring an action.
URL:: ServiceNow instance URL.
@ -55,12 +55,12 @@ Secrets defines sensitive information for the connector type.
[[define-servicenow-ui]]
==== Define connector in Stack Management
Define ServiceNow connector properties.
Define ServiceNow ITSM connector properties.
[role="screenshot"]
image::management/connectors/images/servicenow-connector.png[ServiceNow connector]
Test ServiceNow action parameters.
Test ServiceNow ITSM action parameters.
[role="screenshot"]
image::management/connectors/images/servicenow-params-test.png[ServiceNow params test]
@ -69,11 +69,13 @@ image::management/connectors/images/servicenow-params-test.png[ServiceNow params
[[servicenow-action-configuration]]
==== Action configuration
ServiceNow actions have the following configuration properties.
ServiceNow ITSM actions have the following configuration properties.
Urgency:: The extent to which the incident resolution can delay.
Severity:: The severity of the incident.
Impact:: The effect an incident has on business. Can be measured by the number of affected users or by how critical it is to the business in question.
Category:: The category of the incident.
Subcategory:: The category of the incident.
Short description:: A short description for the incident, used for searching the contents of the knowledge base.
Description:: The details about the incident.
Additional comments:: Additional information for the client, such as how to troubleshoot the issue.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

View file

@ -6,6 +6,7 @@ include::action-types/teams.asciidoc[]
include::action-types/pagerduty.asciidoc[]
include::action-types/server-log.asciidoc[]
include::action-types/servicenow.asciidoc[]
include::action-types/servicenow-sir.asciidoc[]
include::action-types/swimlane.asciidoc[]
include::action-types/slack.asciidoc[]
include::action-types/webhook.asciidoc[]

View file

@ -33,29 +33,36 @@ Table of Contents
- [actionsClient.execute(options)](#actionsclientexecuteoptions)
- [Example](#example-2)
- [Built-in Action Types](#built-in-action-types)
- [ServiceNow](#servicenow)
- [ServiceNow ITSM](#servicenow-itsm)
- [`params`](#params)
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice)
- [`subActionParams (getFields)`](#subactionparams-getfields)
- [`subActionParams (getIncident)`](#subactionparams-getincident)
- [`subActionParams (getChoices)`](#subactionparams-getchoices)
- [Jira](#jira)
- [ServiceNow Sec Ops](#servicenow-sec-ops)
- [`params`](#params-1)
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-1)
- [`subActionParams (getFields)`](#subactionparams-getfields-1)
- [`subActionParams (getIncident)`](#subactionparams-getincident-1)
- [`subActionParams (getChoices)`](#subactionparams-getchoices-1)
- [| fields | An array of fields. Example: `[priority, category]`. | string[] |](#-fields----an-array-of-fields-example-priority-category--string-)
- [Jira](#jira)
- [`params`](#params-2)
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2)
- [`subActionParams (getIncident)`](#subactionparams-getincident-2)
- [`subActionParams (issueTypes)`](#subactionparams-issuetypes)
- [`subActionParams (fieldsByIssueType)`](#subactionparams-fieldsbyissuetype)
- [`subActionParams (issues)`](#subactionparams-issues)
- [`subActionParams (issue)`](#subactionparams-issue)
- [`subActionParams (getFields)`](#subactionparams-getfields-1)
- [IBM Resilient](#ibm-resilient)
- [`params`](#params-2)
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2)
- [`subActionParams (getFields)`](#subactionparams-getfields-2)
- [IBM Resilient](#ibm-resilient)
- [`params`](#params-3)
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3)
- [`subActionParams (getFields)`](#subactionparams-getfields-3)
- [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes)
- [`subActionParams (severity)`](#subactionparams-severity)
- [Swimlane](#swimlane)
- [`params`](#params-3)
- [`params`](#params-4)
- [| severity | The severity of the incident. | string _(optional)_ |](#-severity-----the-severity-of-the-incident-----string-optional-)
- [Command Line Utility](#command-line-utility)
- [Developing New Action Types](#developing-new-action-types)
@ -246,9 +253,9 @@ Kibana ships with a set of built-in action types. See [Actions and connector typ
In addition to the documented configurations, several built in action type offer additional `params` configurations.
## ServiceNow
## ServiceNow ITSM
The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
The [ServiceNow ITSM user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
### `params`
| Property | Description | Type |
@ -265,16 +272,18 @@ The [ServiceNow user documentation `params`](https://www.elastic.co/guide/en/kib
The following table describes the properties of the `incident` object.
| Property | Description | Type |
| ----------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- |
| short_description | The title of the incident. | string |
| description | The description of the incident. | string _(optional)_ |
| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
| severity | The severity in ServiceNow. | string _(optional)_ |
| urgency | The urgency in ServiceNow. | string _(optional)_ |
| impact | The impact in ServiceNow. | string _(optional)_ |
| category | The category in ServiceNow. | string _(optional)_ |
| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
| Property | Description | Type |
| ------------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------- |
| short_description | The title of the incident. | string |
| description | The description of the incident. | string _(optional)_ |
| externalId | The ID of the incident in ServiceNow. If present, the incident is updated. Otherwise, a new incident is created. | string _(optional)_ |
| severity | The severity in ServiceNow. | string _(optional)_ |
| urgency | The urgency in ServiceNow. | string _(optional)_ |
| impact | The impact in ServiceNow. | string _(optional)_ |
| category | The category in ServiceNow. | string _(optional)_ |
| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
| correlation_id | The correlation id of the incident. | string _(optional)_ |
| correlation_display | The correlation display of the ServiceNow. | string _(optional)_ |
#### `subActionParams (getFields)`
@ -289,12 +298,64 @@ No parameters for the `getFields` subaction. Provide an empty object `{}`.
#### `subActionParams (getChoices)`
| Property | Description | Type |
| -------- | ------------------------------------------------------------ | -------- |
| fields | An array of fields. Example: `[priority, category, impact]`. | string[] |
| Property | Description | Type |
| -------- | -------------------------------------------------- | -------- |
| fields | An array of fields. Example: `[category, impact]`. | string[] |
---
## ServiceNow Sec Ops
The [ServiceNow SecOps user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-sir-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
### `params`
| Property | Description | Type |
| --------------- | -------------------------------------------------------------------------------------------------- | ------ |
| subAction | The subaction to perform. It can be `pushToService`, `getFields`, `getIncident`, and `getChoices`. | string |
| subActionParams | The parameters of the subaction. | object |
#### `subActionParams (pushToService)`
| Property | Description | Type |
| -------- | ------------------------------------------------------------------------------------------------------------- | --------------------- |
| incident | The ServiceNow security incident. | object |
| comments | The comments of the case. A comment is of the form `{ commentId: string, version: string, comment: string }`. | object[] _(optional)_ |
The following table describes the properties of the `incident` object.
| Property | Description | Type |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- |
| short_description | The title of the security incident. | string |
| description | The description of the security incident. | string _(optional)_ |
| externalId | The ID of the security incident in ServiceNow. If present, the security incident is updated. Otherwise, a new security incident is created. | string _(optional)_ |
| priority | The priority in ServiceNow. | string _(optional)_ |
| dest_ip | A list of destination IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
| source_ip | A list of source IPs related to the security incident. The IPs will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
| malware_hash | A list of malware hashes related to the security incident. The hashes will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
| malware_url | A list of malware URLs related to the security incident. The URLs will be added as observables to the security incident. | (string \| string[]) _(optional)_ |
| category | The category in ServiceNow. | string _(optional)_ |
| subcategory | The subcategory in ServiceNow. | string _(optional)_ |
| correlation_id | The correlation id of the security incident. | string _(optional)_ |
| correlation_display | The correlation display of the security incident. | string _(optional)_ |
#### `subActionParams (getFields)`
No parameters for the `getFields` subaction. Provide an empty object `{}`.
#### `subActionParams (getIncident)`
| Property | Description | Type |
| ---------- | ---------------------------------------------- | ------ |
| externalId | The ID of the security incident in ServiceNow. | string |
#### `subActionParams (getChoices)`
| Property | Description | Type |
| -------- | ---------------------------------------------------- | -------- |
| fields | An array of fields. Example: `[priority, category]`. | string[] |
---
## Jira
The [Jira user documentation `params`](https://www.elastic.co/guide/en/kibana/master/jira-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.

View file

@ -143,7 +143,7 @@ export function getActionType({
}),
validate: {
config: schema.object(configSchemaProps, {
validate: curry(valdiateActionTypeConfig)(configurationUtilities),
validate: curry(validateActionTypeConfig)(configurationUtilities),
}),
secrets: SecretsSchema,
params: ParamsSchema,
@ -152,7 +152,7 @@ export function getActionType({
};
}
function valdiateActionTypeConfig(
function validateActionTypeConfig(
configurationUtilities: ActionsConfigurationUtilities,
configObject: ActionTypeConfigType
) {

View file

@ -25,6 +25,7 @@ describe('api', () => {
const res = await api.pushToService({
externalService,
params,
config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@ -57,6 +58,7 @@ describe('api', () => {
const res = await api.pushToService({
externalService,
params,
config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@ -78,6 +80,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
config: {},
secrets: { username: 'elastic', password: 'elastic' },
logger: mockedLogger,
commentFieldKey: 'comments',
@ -93,6 +96,9 @@ describe('api', () => {
caller_id: 'elastic',
description: 'Incident description',
short_description: 'Incident title',
correlation_display: 'Alerting',
correlation_id: 'ruleId',
opened_by: 'elastic',
},
});
expect(externalService.updateIncident).not.toHaveBeenCalled();
@ -103,6 +109,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@ -118,6 +125,8 @@ describe('api', () => {
comments: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
correlation_display: 'Alerting',
correlation_id: 'ruleId',
},
incidentId: 'incident-1',
});
@ -132,6 +141,8 @@ describe('api', () => {
comments: 'Another comment',
description: 'Incident description',
short_description: 'Incident title',
correlation_display: 'Alerting',
correlation_id: 'ruleId',
},
incidentId: 'incident-1',
});
@ -142,6 +153,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'work_notes',
@ -157,6 +169,8 @@ describe('api', () => {
work_notes: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
correlation_display: 'Alerting',
correlation_id: 'ruleId',
},
incidentId: 'incident-1',
});
@ -171,6 +185,8 @@ describe('api', () => {
work_notes: 'Another comment',
description: 'Incident description',
short_description: 'Incident title',
correlation_display: 'Alerting',
correlation_id: 'ruleId',
},
incidentId: 'incident-1',
});
@ -182,6 +198,7 @@ describe('api', () => {
const res = await api.pushToService({
externalService,
params: apiParams,
config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@ -210,6 +227,7 @@ describe('api', () => {
const res = await api.pushToService({
externalService,
params,
config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@ -228,6 +246,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@ -243,6 +262,8 @@ describe('api', () => {
subcategory: 'os',
description: 'Incident description',
short_description: 'Incident title',
correlation_display: 'Alerting',
correlation_id: 'ruleId',
},
});
expect(externalService.createIncident).not.toHaveBeenCalled();
@ -253,6 +274,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'comments',
@ -267,6 +289,8 @@ describe('api', () => {
subcategory: 'os',
description: 'Incident description',
short_description: 'Incident title',
correlation_display: 'Alerting',
correlation_id: 'ruleId',
},
incidentId: 'incident-3',
});
@ -281,6 +305,8 @@ describe('api', () => {
comments: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
correlation_display: 'Alerting',
correlation_id: 'ruleId',
},
incidentId: 'incident-2',
});
@ -291,6 +317,7 @@ describe('api', () => {
await api.pushToService({
externalService,
params,
config: {},
secrets: {},
logger: mockedLogger,
commentFieldKey: 'work_notes',
@ -305,6 +332,8 @@ describe('api', () => {
subcategory: 'os',
description: 'Incident description',
short_description: 'Incident title',
correlation_display: 'Alerting',
correlation_id: 'ruleId',
},
incidentId: 'incident-3',
});
@ -319,6 +348,8 @@ describe('api', () => {
work_notes: 'A comment',
description: 'Incident description',
short_description: 'Incident title',
correlation_display: 'Alerting',
correlation_id: 'ruleId',
},
incidentId: 'incident-2',
});
@ -344,4 +375,23 @@ describe('api', () => {
expect(res).toEqual(serviceNowChoices);
});
});
describe('getIncident', () => {
test('it gets the incident correctly', async () => {
const res = await api.getIncident({
externalService,
params: {
externalId: 'incident-1',
},
});
expect(res).toEqual({
description: 'description from servicenow',
id: 'incident-1',
pushedDate: '2020-03-10T12:24:20.000Z',
short_description: 'title from servicenow',
title: 'INC01',
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
});
});
});
});

View file

@ -6,7 +6,7 @@
*/
import {
ExternalServiceApi,
ExternalServiceAPI,
GetChoicesHandlerArgs,
GetChoicesResponse,
GetCommonFieldsHandlerArgs,
@ -19,7 +19,11 @@ import {
} from './types';
const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {};
const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {};
const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {
const { externalId: id } = params;
const res = await externalService.getIncident(id);
return res;
};
const pushToServiceHandler = async ({
externalService,
@ -42,6 +46,7 @@ const pushToServiceHandler = async ({
incident: {
...incident,
caller_id: secrets.username,
opened_by: secrets.username,
},
});
}
@ -84,7 +89,7 @@ const getChoicesHandler = async ({
return res;
};
export const api: ExternalServiceApi = {
export const api: ExternalServiceAPI = {
getChoices: getChoicesHandler,
getFields: getFieldsHandler,
getIncident: getIncidentHandler,

View file

@ -0,0 +1,286 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Logger } from '../../../../../../src/core/server';
import { externalServiceSIRMock, sirParams } from './mocks';
import { ExternalServiceSIR, ObservableTypes } from './types';
import { apiSIR, combineObservables, formatObservables, prepareParams } from './api_sir';
let mockedLogger: jest.Mocked<Logger>;
describe('api_sir', () => {
let externalService: jest.Mocked<ExternalServiceSIR>;
beforeEach(() => {
externalService = externalServiceSIRMock.create();
jest.clearAllMocks();
});
describe('combineObservables', () => {
test('it returns an empty array when both arguments are an empty array', async () => {
expect(combineObservables([], [])).toEqual([]);
});
test('it returns an empty array when both arguments are an empty string', async () => {
expect(combineObservables('', '')).toEqual([]);
});
test('it returns an empty array when a="" and b=[]', async () => {
expect(combineObservables('', [])).toEqual([]);
});
test('it returns an empty array when a=[] and b=""', async () => {
expect(combineObservables([], '')).toEqual([]);
});
test('it returns a if b is empty', async () => {
expect(combineObservables('a', '')).toEqual(['a']);
});
test('it returns b if a is empty', async () => {
expect(combineObservables([], ['b'])).toEqual(['b']);
});
test('it combines two strings', async () => {
expect(combineObservables('a,b', 'c,d')).toEqual(['a', 'b', 'c', 'd']);
});
test('it combines two arrays', async () => {
expect(combineObservables(['a'], ['b'])).toEqual(['a', 'b']);
});
test('it combines a string with an array', async () => {
expect(combineObservables('a', ['b'])).toEqual(['a', 'b']);
});
test('it combines an array with a string ', async () => {
expect(combineObservables(['a'], 'b')).toEqual(['a', 'b']);
});
test('it combines a "," concatenated string', async () => {
expect(combineObservables(['a'], 'b,c,d')).toEqual(['a', 'b', 'c', 'd']);
expect(combineObservables('b,c,d', ['a'])).toEqual(['b', 'c', 'd', 'a']);
});
test('it combines a "|" concatenated string', async () => {
expect(combineObservables(['a'], 'b|c|d')).toEqual(['a', 'b', 'c', 'd']);
expect(combineObservables('b|c|d', ['a'])).toEqual(['b', 'c', 'd', 'a']);
});
test('it combines a space concatenated string', async () => {
expect(combineObservables(['a'], 'b c d')).toEqual(['a', 'b', 'c', 'd']);
expect(combineObservables('b c d', ['a'])).toEqual(['b', 'c', 'd', 'a']);
});
test('it combines a "\\n" concatenated string', async () => {
expect(combineObservables(['a'], 'b\nc\nd')).toEqual(['a', 'b', 'c', 'd']);
expect(combineObservables('b\nc\nd', ['a'])).toEqual(['b', 'c', 'd', 'a']);
});
test('it combines a "\\r" concatenated string', async () => {
expect(combineObservables(['a'], 'b\rc\rd')).toEqual(['a', 'b', 'c', 'd']);
expect(combineObservables('b\rc\rd', ['a'])).toEqual(['b', 'c', 'd', 'a']);
});
test('it combines a "\\t" concatenated string', async () => {
expect(combineObservables(['a'], 'b\tc\td')).toEqual(['a', 'b', 'c', 'd']);
expect(combineObservables('b\tc\td', ['a'])).toEqual(['b', 'c', 'd', 'a']);
});
test('it combines two strings with different delimiter', async () => {
expect(combineObservables('a|b|c', 'd e f')).toEqual(['a', 'b', 'c', 'd', 'e', 'f']);
});
});
describe('formatObservables', () => {
test('it formats array observables correctly', async () => {
const expectedTypes: Array<[ObservableTypes, string]> = [
[ObservableTypes.ip4, 'ipv4-addr'],
[ObservableTypes.sha256, 'SHA256'],
[ObservableTypes.url, 'URL'],
];
for (const type of expectedTypes) {
expect(formatObservables(['a', 'b', 'c'], type[0])).toEqual([
{ type: type[1], value: 'a' },
{ type: type[1], value: 'b' },
{ type: type[1], value: 'c' },
]);
}
});
test('it removes duplicates from array observables correctly', async () => {
expect(formatObservables(['a', 'a', 'c'], ObservableTypes.ip4)).toEqual([
{ type: 'ipv4-addr', value: 'a' },
{ type: 'ipv4-addr', value: 'c' },
]);
});
test('it formats an empty array correctly', async () => {
expect(formatObservables([], ObservableTypes.ip4)).toEqual([]);
});
test('it removes empty observables correctly', async () => {
expect(formatObservables(['a', '', 'c'], ObservableTypes.ip4)).toEqual([
{ type: 'ipv4-addr', value: 'a' },
{ type: 'ipv4-addr', value: 'c' },
]);
});
});
describe('prepareParams', () => {
test('it prepares the params correctly when the connector is legacy', async () => {
expect(prepareParams(true, sirParams)).toEqual({
...sirParams,
incident: {
...sirParams.incident,
dest_ip: '192.168.1.1,192.168.1.3',
source_ip: '192.168.1.2,192.168.1.4',
malware_hash: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
malware_url: 'https://example.com',
},
});
});
test('it prepares the params correctly when the connector is not legacy', async () => {
expect(prepareParams(false, sirParams)).toEqual({
...sirParams,
incident: {
...sirParams.incident,
dest_ip: null,
source_ip: null,
malware_hash: null,
malware_url: null,
},
});
});
test('it prepares the params correctly when the connector is legacy and the observables are undefined', async () => {
const {
dest_ip: destIp,
source_ip: sourceIp,
malware_hash: malwareHash,
malware_url: malwareURL,
...incidentWithoutObservables
} = sirParams.incident;
expect(
prepareParams(true, {
...sirParams,
// @ts-expect-error
incident: incidentWithoutObservables,
})
).toEqual({
...sirParams,
incident: {
...sirParams.incident,
dest_ip: null,
source_ip: null,
malware_hash: null,
malware_url: null,
},
});
});
});
describe('pushToService', () => {
test('it creates an incident correctly', async () => {
const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } };
const res = await apiSIR.pushToService({
externalService,
params,
config: { isLegacy: false },
secrets: {},
logger: mockedLogger,
commentFieldKey: 'work_notes',
});
expect(res).toEqual({
id: 'incident-1',
title: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
comments: [
{
commentId: 'case-comment-1',
pushedDate: '2020-03-10T12:24:20.000Z',
},
{
commentId: 'case-comment-2',
pushedDate: '2020-03-10T12:24:20.000Z',
},
],
});
});
test('it adds observables correctly', async () => {
const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } };
await apiSIR.pushToService({
externalService,
params,
config: { isLegacy: false },
secrets: {},
logger: mockedLogger,
commentFieldKey: 'work_notes',
});
expect(externalService.bulkAddObservableToIncident).toHaveBeenCalledWith(
[
{ type: 'ipv4-addr', value: '192.168.1.1' },
{ type: 'ipv4-addr', value: '192.168.1.3' },
{ type: 'ipv4-addr', value: '192.168.1.2' },
{ type: 'ipv4-addr', value: '192.168.1.4' },
{
type: 'SHA256',
value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
},
{ type: 'URL', value: 'https://example.com' },
],
// createIncident mock returns this incident id
'incident-1'
);
});
test('it does not call bulkAddObservableToIncident if it a legacy connector', async () => {
const params = { ...sirParams, incident: { ...sirParams.incident, externalId: null } };
await apiSIR.pushToService({
externalService,
params,
config: { isLegacy: true },
secrets: {},
logger: mockedLogger,
commentFieldKey: 'work_notes',
});
expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled();
});
test('it does not call bulkAddObservableToIncident if there are no observables', async () => {
const params = {
...sirParams,
incident: {
...sirParams.incident,
dest_ip: null,
source_ip: null,
malware_hash: null,
malware_url: null,
externalId: null,
},
};
await apiSIR.pushToService({
externalService,
params,
config: { isLegacy: false },
secrets: {},
logger: mockedLogger,
commentFieldKey: 'work_notes',
});
expect(externalService.bulkAddObservableToIncident).not.toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,154 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEmpty, isString } from 'lodash';
import {
ExecutorSubActionPushParamsSIR,
ExternalServiceAPI,
ExternalServiceSIR,
ObservableTypes,
PushToServiceApiHandlerArgs,
PushToServiceApiParamsSIR,
PushToServiceResponse,
} from './types';
import { api } from './api';
const SPLIT_REGEX = /[ ,|\r\n\t]+/;
export const formatObservables = (observables: string[], type: ObservableTypes) => {
/**
* ServiceNow accepted formats are: comma, new line, tab, or pipe separators.
* Before the application the observables were being sent to ServiceNow as a concatenated string with
* delimiter. With the application the format changed to an array of observables.
*/
const uniqueObservables = new Set(observables);
return [...uniqueObservables].filter((obs) => !isEmpty(obs)).map((obs) => ({ value: obs, type }));
};
const obsAsArray = (obs: string | string[]): string[] => {
if (isEmpty(obs)) {
return [];
}
if (isString(obs)) {
return obs.split(SPLIT_REGEX);
}
return obs;
};
export const combineObservables = (a: string | string[], b: string | string[]): string[] => {
const first = obsAsArray(a);
const second = obsAsArray(b);
return [...first, ...second];
};
const observablesToString = (obs: string | string[] | null | undefined): string | null => {
if (Array.isArray(obs)) {
return obs.join(',');
}
return obs ?? null;
};
export const prepareParams = (
isLegacy: boolean,
params: PushToServiceApiParamsSIR
): PushToServiceApiParamsSIR => {
if (isLegacy) {
/**
* The schema has change to accept an array of observables
* or a string. In the case of a legacy connector we need to
* convert the observables to a string
*/
return {
...params,
incident: {
...params.incident,
dest_ip: observablesToString(params.incident.dest_ip),
malware_hash: observablesToString(params.incident.malware_hash),
malware_url: observablesToString(params.incident.malware_url),
source_ip: observablesToString(params.incident.source_ip),
},
};
}
/**
* For non legacy connectors the observables
* will be added in a different call.
* They need to be set to null when sending the fields
* to ServiceNow
*/
return {
...params,
incident: {
...params.incident,
dest_ip: null,
malware_hash: null,
malware_url: null,
source_ip: null,
},
};
};
const pushToServiceHandler = async ({
externalService,
params,
config,
secrets,
commentFieldKey,
logger,
}: PushToServiceApiHandlerArgs): Promise<PushToServiceResponse> => {
const res = await api.pushToService({
externalService,
params: prepareParams(!!config.isLegacy, params as PushToServiceApiParamsSIR),
config,
secrets,
commentFieldKey,
logger,
});
const {
incident: {
dest_ip: destIP,
malware_hash: malwareHash,
malware_url: malwareUrl,
source_ip: sourceIP,
},
} = params as ExecutorSubActionPushParamsSIR;
/**
* Add bulk observables is only available for new connectors
* Old connectors gonna add their observables
* through the pushToService call.
*/
if (!config.isLegacy) {
const sirExternalService = externalService as ExternalServiceSIR;
const obsWithType: Array<[string[], ObservableTypes]> = [
[combineObservables(destIP ?? [], sourceIP ?? []), ObservableTypes.ip4],
[obsAsArray(malwareHash ?? []), ObservableTypes.sha256],
[obsAsArray(malwareUrl ?? []), ObservableTypes.url],
];
const observables = obsWithType.map(([obs, type]) => formatObservables(obs, type)).flat();
if (observables.length > 0) {
await sirExternalService.bulkAddObservableToIncident(observables, res.id);
}
}
return res;
};
export const apiSIR: ExternalServiceAPI = {
...api,
pushToService: pushToServiceHandler,
};

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { snExternalServiceConfig } from './config';
/**
* The purpose of this test is to
* prevent developers from accidentally
* change important configuration values
* such as the scope or the import set table
* of our ServiceNow application
*/
describe('config', () => {
test('ITSM: the config are correct', async () => {
const snConfig = snExternalServiceConfig['.servicenow'];
expect(snConfig).toEqual({
importSetTable: 'x_elas2_inc_int_elastic_incident',
appScope: 'x_elas2_inc_int',
table: 'incident',
useImportAPI: true,
commentFieldKey: 'work_notes',
});
});
test('SIR: the config are correct', async () => {
const snConfig = snExternalServiceConfig['.servicenow-sir'];
expect(snConfig).toEqual({
importSetTable: 'x_elas2_sir_int_elastic_si_incident',
appScope: 'x_elas2_sir_int',
table: 'sn_si_incident',
useImportAPI: true,
commentFieldKey: 'work_notes',
});
});
});

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
ENABLE_NEW_SN_ITSM_CONNECTOR,
ENABLE_NEW_SN_SIR_CONNECTOR,
} from '../../constants/connectors';
import { SNProductsConfig } from './types';
export const serviceNowITSMTable = 'incident';
export const serviceNowSIRTable = 'sn_si_incident';
export const ServiceNowITSMActionTypeId = '.servicenow';
export const ServiceNowSIRActionTypeId = '.servicenow-sir';
export const snExternalServiceConfig: SNProductsConfig = {
'.servicenow': {
importSetTable: 'x_elas2_inc_int_elastic_incident',
appScope: 'x_elas2_inc_int',
table: 'incident',
useImportAPI: ENABLE_NEW_SN_ITSM_CONNECTOR,
commentFieldKey: 'work_notes',
},
'.servicenow-sir': {
importSetTable: 'x_elas2_sir_int_elastic_si_incident',
appScope: 'x_elas2_sir_int',
table: 'sn_si_incident',
useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR,
commentFieldKey: 'work_notes',
},
};
export const FIELD_PREFIX = 'u_';

View file

@ -18,7 +18,7 @@ import {
import { ActionsConfigurationUtilities } from '../../actions_config';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
import { createExternalService } from './service';
import { api } from './api';
import { api as commonAPI } from './api';
import * as i18n from './translations';
import { Logger } from '../../../../../../src/core/server';
import {
@ -30,7 +30,25 @@ import {
ExecutorSubActionCommonFieldsParams,
ServiceNowExecutorResultData,
ExecutorSubActionGetChoicesParams,
ServiceFactory,
ExternalServiceAPI,
} from './types';
import {
ServiceNowITSMActionTypeId,
serviceNowITSMTable,
ServiceNowSIRActionTypeId,
serviceNowSIRTable,
snExternalServiceConfig,
} from './config';
import { createExternalServiceSIR } from './service_sir';
import { apiSIR } from './api_sir';
export {
ServiceNowITSMActionTypeId,
serviceNowITSMTable,
ServiceNowSIRActionTypeId,
serviceNowSIRTable,
};
export type ActionParamsType =
| TypeOf<typeof ExecutorParamsSchemaITSM>
@ -41,12 +59,6 @@ interface GetActionTypeParams {
configurationUtilities: ActionsConfigurationUtilities;
}
const serviceNowITSMTable = 'incident';
const serviceNowSIRTable = 'sn_si_incident';
export const ServiceNowITSMActionTypeId = '.servicenow';
export const ServiceNowSIRActionTypeId = '.servicenow-sir';
export type ServiceNowActionType = ActionType<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
@ -79,8 +91,9 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic
executor: curry(executor)({
logger,
configurationUtilities,
table: serviceNowITSMTable,
commentFieldKey: 'work_notes',
actionTypeId: ServiceNowITSMActionTypeId,
createService: createExternalService,
api: commonAPI,
}),
};
}
@ -103,8 +116,9 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service
executor: curry(executor)({
logger,
configurationUtilities,
table: serviceNowSIRTable,
commentFieldKey: 'work_notes',
actionTypeId: ServiceNowSIRActionTypeId,
createService: createExternalServiceSIR,
api: apiSIR,
}),
};
}
@ -115,28 +129,31 @@ async function executor(
{
logger,
configurationUtilities,
table,
commentFieldKey = 'comments',
actionTypeId,
createService,
api,
}: {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
table: string;
commentFieldKey?: string;
actionTypeId: string;
createService: ServiceFactory;
api: ExternalServiceAPI;
},
execOptions: ServiceNowActionTypeExecutorOptions
): Promise<ActionTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
const { actionId, config, params, secrets } = execOptions;
const { subAction, subActionParams } = params;
const externalServiceConfig = snExternalServiceConfig[actionTypeId];
let data: ServiceNowExecutorResultData | null = null;
const externalService = createExternalService(
table,
const externalService = createService(
{
config,
secrets,
},
logger,
configurationUtilities
configurationUtilities,
externalServiceConfig
);
if (!api[subAction]) {
@ -156,9 +173,10 @@ async function executor(
data = await api.pushToService({
externalService,
params: pushToServiceParams,
config,
secrets,
logger,
commentFieldKey,
commentFieldKey: externalServiceConfig.commentFieldKey,
});
logger.debug(`response push to service for incident id: ${data.id}`);

View file

@ -5,7 +5,14 @@
* 2.0.
*/
import { ExternalService, ExecutorSubActionPushParams } from './types';
import {
ExternalService,
ExecutorSubActionPushParams,
PushToServiceApiParamsSIR,
ExternalServiceSIR,
Observable,
ObservableTypes,
} from './types';
export const serviceNowCommonFields = [
{
@ -74,6 +81,10 @@ const createMock = (): jest.Mocked<ExternalService> => {
getFields: jest.fn().mockImplementation(() => Promise.resolve(serviceNowCommonFields)),
getIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
id: 'incident-1',
title: 'INC01',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://instance.service-now.com/nav_to.do?uri=incident.do?sys_id=123',
short_description: 'title from servicenow',
description: 'description from servicenow',
})
@ -95,16 +106,60 @@ const createMock = (): jest.Mocked<ExternalService> => {
})
),
findIncidents: jest.fn(),
getApplicationInformation: jest.fn().mockImplementation(() =>
Promise.resolve({
name: 'Elastic',
scope: 'x_elas2_inc_int',
version: '1.0.0',
})
),
checkIfApplicationIsInstalled: jest.fn(),
getUrl: jest.fn().mockImplementation(() => 'https://instance.service-now.com'),
checkInstance: jest.fn(),
};
return service;
};
const externalServiceMock = {
const createSIRMock = (): jest.Mocked<ExternalServiceSIR> => {
const service = {
...createMock(),
addObservableToIncident: jest.fn().mockImplementation(() =>
Promise.resolve({
value: 'https://example.com',
observable_sys_id: '3',
})
),
bulkAddObservableToIncident: jest.fn().mockImplementation(() =>
Promise.resolve([
{
value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
observable_sys_id: '1',
},
{
value: '127.0.0.1',
observable_sys_id: '2',
},
{
value: 'https://example.com',
observable_sys_id: '3',
},
])
),
};
return service;
};
export const externalServiceMock = {
create: createMock,
};
const executorParams: ExecutorSubActionPushParams = {
export const externalServiceSIRMock = {
create: createSIRMock,
};
export const executorParams: ExecutorSubActionPushParams = {
incident: {
externalId: 'incident-3',
short_description: 'Incident title',
@ -114,6 +169,8 @@ const executorParams: ExecutorSubActionPushParams = {
impact: '3',
category: 'software',
subcategory: 'os',
correlation_id: 'ruleId',
correlation_display: 'Alerting',
},
comments: [
{
@ -127,6 +184,46 @@ const executorParams: ExecutorSubActionPushParams = {
],
};
const apiParams = executorParams;
export const sirParams: PushToServiceApiParamsSIR = {
incident: {
externalId: 'incident-3',
short_description: 'Incident title',
description: 'Incident description',
dest_ip: ['192.168.1.1', '192.168.1.3'],
source_ip: ['192.168.1.2', '192.168.1.4'],
malware_hash: ['5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9'],
malware_url: ['https://example.com'],
category: 'software',
subcategory: 'os',
correlation_id: 'ruleId',
correlation_display: 'Alerting',
priority: '1',
},
comments: [
{
commentId: 'case-comment-1',
comment: 'A comment',
},
{
commentId: 'case-comment-2',
comment: 'Another comment',
},
],
};
export { externalServiceMock, executorParams, apiParams };
export const observables: Observable[] = [
{
value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
type: ObservableTypes.sha256,
},
{
value: '127.0.0.1',
type: ObservableTypes.ip4,
},
{
value: 'https://example.com',
type: ObservableTypes.url,
},
];
export const apiParams = executorParams;

View file

@ -9,6 +9,7 @@ import { schema } from '@kbn/config-schema';
export const ExternalIncidentServiceConfiguration = {
apiUrl: schema.string(),
isLegacy: schema.boolean({ defaultValue: false }),
};
export const ExternalIncidentServiceConfigurationSchema = schema.object(
@ -39,6 +40,8 @@ const CommonAttributes = {
externalId: schema.nullable(schema.string()),
category: schema.nullable(schema.string()),
subcategory: schema.nullable(schema.string()),
correlation_id: schema.nullable(schema.string()),
correlation_display: schema.nullable(schema.string()),
};
// Schema for ServiceNow Incident Management (ITSM)
@ -56,10 +59,22 @@ export const ExecutorSubActionPushParamsSchemaITSM = schema.object({
export const ExecutorSubActionPushParamsSchemaSIR = schema.object({
incident: schema.object({
...CommonAttributes,
dest_ip: schema.nullable(schema.string()),
malware_hash: schema.nullable(schema.string()),
malware_url: schema.nullable(schema.string()),
source_ip: schema.nullable(schema.string()),
dest_ip: schema.oneOf(
[schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
{ defaultValue: null }
),
malware_hash: schema.oneOf(
[schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
{ defaultValue: null }
),
malware_url: schema.oneOf(
[schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
{ defaultValue: null }
),
source_ip: schema.oneOf(
[schema.nullable(schema.string()), schema.nullable(schema.arrayOf(schema.string()))],
{ defaultValue: null }
),
priority: schema.nullable(schema.string()),
}),
comments: CommentsSchema,

View file

@ -5,15 +5,16 @@
* 2.0.
*/
import axios from 'axios';
import axios, { AxiosResponse } from 'axios';
import { createExternalService } from './service';
import * as utils from '../lib/axios_utils';
import { ExternalService } from './types';
import { ExternalService, ServiceNowITSMIncident } from './types';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { actionsConfigMock } from '../../actions_config.mock';
import { serviceNowCommonFields, serviceNowChoices } from './mocks';
import { snExternalServiceConfig } from './config';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
@ -28,24 +29,134 @@ jest.mock('../lib/axios_utils', () => {
axios.create = jest.fn(() => axios);
const requestMock = utils.request as jest.Mock;
const patchMock = utils.patch as jest.Mock;
const configurationUtilities = actionsConfigMock.create();
const table = 'incident';
const getImportSetAPIResponse = (update = false) => ({
import_set: 'ISET01',
staging_table: 'x_elas2_inc_int_elastic_incident',
result: [
{
transform_map: 'Elastic Incident',
table: 'incident',
display_name: 'number',
display_value: 'INC01',
record_link: 'https://example.com/api/now/table/incident/1',
status: update ? 'updated' : 'inserted',
sys_id: '1',
},
],
});
const getImportSetAPIError = () => ({
import_set: 'ISET01',
staging_table: 'x_elas2_inc_int_elastic_incident',
result: [
{
transform_map: 'Elastic Incident',
status: 'error',
error_message: 'An error has occurred while importing the incident',
status_message: 'failure',
},
],
});
const mockApplicationVersion = () =>
requestMock.mockImplementationOnce(() => ({
data: {
result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' },
},
}));
const mockImportIncident = (update: boolean) =>
requestMock.mockImplementationOnce(() => ({
data: getImportSetAPIResponse(update),
}));
const mockIncidentResponse = (update: boolean) =>
requestMock.mockImplementation(() => ({
data: {
result: {
sys_id: '1',
number: 'INC01',
...(update
? { sys_updated_on: '2020-03-10 12:24:20' }
: { sys_created_on: '2020-03-10 12:24:20' }),
},
},
}));
const createIncident = async (service: ExternalService) => {
// Get application version
mockApplicationVersion();
// Import set api response
mockImportIncident(false);
// Get incident response
mockIncidentResponse(false);
return await service.createIncident({
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
});
};
const updateIncident = async (service: ExternalService) => {
// Get application version
mockApplicationVersion();
// Import set api response
mockImportIncident(true);
// Get incident response
mockIncidentResponse(true);
return await service.updateIncident({
incidentId: '1',
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
});
};
const expectImportedIncident = (update: boolean) => {
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/x_elas2_inc_int/elastic_api/health',
method: 'get',
});
expect(requestMock).toHaveBeenNthCalledWith(2, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/import/x_elas2_inc_int_elastic_incident',
method: 'post',
data: {
u_short_description: 'title',
u_description: 'desc',
...(update ? { elastic_incident_id: '1' } : {}),
},
});
expect(requestMock).toHaveBeenNthCalledWith(3, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/incident/1',
method: 'get',
});
};
describe('ServiceNow service', () => {
let service: ExternalService;
beforeEach(() => {
service = createExternalService(
table,
{
// The trailing slash at the end of the url is intended.
// All API calls need to have the trailing slash removed.
config: { apiUrl: 'https://dev102283.service-now.com/' },
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
configurationUtilities,
snExternalServiceConfig['.servicenow']
);
});
@ -57,13 +168,13 @@ describe('ServiceNow service', () => {
test('throws without url', () => {
expect(() =>
createExternalService(
table,
{
config: { apiUrl: null },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
configurationUtilities,
snExternalServiceConfig['.servicenow']
)
).toThrow();
});
@ -71,13 +182,13 @@ describe('ServiceNow service', () => {
test('throws without username', () => {
expect(() =>
createExternalService(
table,
{
config: { apiUrl: 'test.com' },
secrets: { username: '', password: 'admin' },
},
logger,
configurationUtilities
configurationUtilities,
snExternalServiceConfig['.servicenow']
)
).toThrow();
});
@ -85,13 +196,13 @@ describe('ServiceNow service', () => {
test('throws without password', () => {
expect(() =>
createExternalService(
table,
{
config: { apiUrl: 'test.com' },
secrets: { username: '', password: undefined },
},
logger,
configurationUtilities
configurationUtilities,
snExternalServiceConfig['.servicenow']
)
).toThrow();
});
@ -116,19 +227,20 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1',
url: 'https://example.com/api/now/v2/table/incident/1',
method: 'get',
});
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
'sn_si_incident',
{
config: { apiUrl: 'https://dev102283.service-now.com/' },
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
configurationUtilities,
{ ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }
);
requestMock.mockImplementation(() => ({
@ -140,7 +252,8 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1',
url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
method: 'get',
});
});
@ -166,214 +279,346 @@ describe('ServiceNow service', () => {
});
describe('createIncident', () => {
test('it creates the incident correctly', async () => {
requestMock.mockImplementation(() => ({
data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } },
}));
const res = await service.createIncident({
incident: { short_description: 'title', description: 'desc' },
// new connectors
describe('import set table', () => {
test('it creates the incident correctly', async () => {
const res = await createIncident(service);
expect(res).toEqual({
title: 'INC01',
id: '1',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
});
});
expect(res).toEqual({
title: 'INC01',
id: '1',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1',
test('it should call request with correct arguments', async () => {
await createIncident(service);
expect(requestMock).toHaveBeenCalledTimes(3);
expectImportedIncident(false);
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
{
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
snExternalServiceConfig['.servicenow-sir']
);
const res = await createIncident(service);
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health',
method: 'get',
});
expect(requestMock).toHaveBeenNthCalledWith(2, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident',
method: 'post',
data: { u_short_description: 'title', u_description: 'desc' },
});
expect(requestMock).toHaveBeenNthCalledWith(3, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
method: 'get',
});
expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
});
test('it should throw an error when the application is not installed', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(
service.createIncident({
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
})
).rejects.toThrow(
'[Action][ServiceNow]: Unable to create incident. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null'
);
});
test('it should throw an error when instance is not alive', async () => {
requestMock.mockImplementation(() => ({
status: 200,
data: {},
request: { connection: { servername: 'Developer instance' } },
}));
await expect(
service.createIncident({
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
})
).rejects.toThrow(
'There is an issue with your Service Now Instance. Please check Developer instance.'
);
});
test('it should throw an error when there is an import set api error', async () => {
requestMock.mockImplementation(() => ({ data: getImportSetAPIError() }));
await expect(
service.createIncident({
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
})
).rejects.toThrow(
'[Action][ServiceNow]: Unable to create incident. Error: An error has occurred while importing the incident Reason: unknown'
);
});
});
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => ({
data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } },
}));
await service.createIncident({
incident: { short_description: 'title', description: 'desc' },
// old connectors
describe('table API', () => {
beforeEach(() => {
service = createExternalService(
{
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
{ ...snExternalServiceConfig['.servicenow'], useImportAPI: false }
);
});
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/incident',
method: 'post',
data: { short_description: 'title', description: 'desc' },
});
});
test('it creates the incident correctly', async () => {
mockIncidentResponse(false);
const res = await service.createIncident({
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
'sn_si_incident',
{
config: { apiUrl: 'https://dev102283.service-now.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
);
expect(res).toEqual({
title: 'INC01',
id: '1',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
});
requestMock.mockImplementation(() => ({
data: { result: { sys_id: '1', number: 'INC01', sys_created_on: '2020-03-10 12:24:20' } },
}));
const res = await service.createIncident({
incident: { short_description: 'title', description: 'desc' },
expect(requestMock).toHaveBeenCalledTimes(2);
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/incident',
method: 'post',
data: { short_description: 'title', description: 'desc' },
});
});
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident',
method: 'post',
data: { short_description: 'title', description: 'desc' },
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
{
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
{ ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }
);
mockIncidentResponse(false);
const res = await service.createIncident({
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
});
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/sn_si_incident',
method: 'post',
data: { short_description: 'title', description: 'desc' },
});
expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
});
expect(res.url).toEqual(
'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'
);
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(
service.createIncident({
incident: { short_description: 'title', description: 'desc' },
})
).rejects.toThrow(
'[Action][ServiceNow]: Unable to create incident. Error: An error has occurred'
);
});
test('it should throw an error when instance is not alive', async () => {
requestMock.mockImplementation(() => ({
status: 200,
data: {},
request: { connection: { servername: 'Developer instance' } },
}));
await expect(service.getIncident('1')).rejects.toThrow(
'There is an issue with your Service Now Instance. Please check Developer instance.'
);
});
});
describe('updateIncident', () => {
test('it updates the incident correctly', async () => {
patchMock.mockImplementation(() => ({
data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
}));
// new connectors
describe('import set table', () => {
test('it updates the incident correctly', async () => {
const res = await updateIncident(service);
const res = await service.updateIncident({
incidentId: '1',
incident: { short_description: 'title', description: 'desc' },
expect(res).toEqual({
title: 'INC01',
id: '1',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
});
});
expect(res).toEqual({
title: 'INC01',
id: '1',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=1',
test('it should call request with correct arguments', async () => {
await updateIncident(service);
expectImportedIncident(true);
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
{
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
snExternalServiceConfig['.servicenow-sir']
);
const res = await updateIncident(service);
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health',
method: 'get',
});
expect(requestMock).toHaveBeenNthCalledWith(2, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/import/x_elas2_sir_int_elastic_si_incident',
method: 'post',
data: { u_short_description: 'title', u_description: 'desc', elastic_incident_id: '1' },
});
expect(requestMock).toHaveBeenNthCalledWith(3, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
method: 'get',
});
expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
});
test('it should throw an error when the application is not installed', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(
service.updateIncident({
incidentId: '1',
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
})
).rejects.toThrow(
'[Action][ServiceNow]: Unable to update incident with id 1. Error: [Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown: errorResponse was null Reason: unknown: errorResponse was null'
);
});
test('it should throw an error when instance is not alive', async () => {
requestMock.mockImplementation(() => ({
status: 200,
data: {},
request: { connection: { servername: 'Developer instance' } },
}));
await expect(
service.updateIncident({
incidentId: '1',
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
})
).rejects.toThrow(
'There is an issue with your Service Now Instance. Please check Developer instance.'
);
});
test('it should throw an error when there is an import set api error', async () => {
requestMock.mockImplementation(() => ({ data: getImportSetAPIError() }));
await expect(
service.updateIncident({
incidentId: '1',
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
})
).rejects.toThrow(
'[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred while importing the incident Reason: unknown'
);
});
});
test('it should call request with correct arguments', async () => {
patchMock.mockImplementation(() => ({
data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
}));
await service.updateIncident({
incidentId: '1',
incident: { short_description: 'title', description: 'desc' },
// old connectors
describe('table API', () => {
beforeEach(() => {
service = createExternalService(
{
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
{ ...snExternalServiceConfig['.servicenow'], useImportAPI: false }
);
});
expect(patchMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1',
data: { short_description: 'title', description: 'desc' },
});
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
'sn_si_incident',
{
config: { apiUrl: 'https://dev102283.service-now.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
);
patchMock.mockImplementation(() => ({
data: { result: { sys_id: '1', number: 'INC01', sys_updated_on: '2020-03-10 12:24:20' } },
}));
const res = await service.updateIncident({
incidentId: '1',
incident: { short_description: 'title', description: 'desc' },
});
expect(patchMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/sn_si_incident/1',
data: { short_description: 'title', description: 'desc' },
});
expect(res.url).toEqual(
'https://dev102283.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=1'
);
});
test('it should throw an error', async () => {
patchMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(
service.updateIncident({
test('it updates the incident correctly', async () => {
mockIncidentResponse(true);
const res = await service.updateIncident({
incidentId: '1',
incident: { short_description: 'title', description: 'desc' },
})
).rejects.toThrow(
'[Action][ServiceNow]: Unable to update incident with id 1. Error: An error has occurred'
);
});
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
});
test('it creates the comment correctly', async () => {
patchMock.mockImplementation(() => ({
data: { result: { sys_id: '11', number: 'INC011', sys_updated_on: '2020-03-10 12:24:20' } },
}));
expect(res).toEqual({
title: 'INC01',
id: '1',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://example.com/nav_to.do?uri=incident.do?sys_id=1',
});
const res = await service.updateIncident({
incidentId: '1',
comment: 'comment-1',
expect(requestMock).toHaveBeenCalledTimes(2);
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/incident/1',
method: 'patch',
data: { short_description: 'title', description: 'desc' },
});
});
expect(res).toEqual({
title: 'INC011',
id: '11',
pushedDate: '2020-03-10T12:24:20.000Z',
url: 'https://dev102283.service-now.com/nav_to.do?uri=incident.do?sys_id=11',
});
});
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
{
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
{ ...snExternalServiceConfig['.servicenow-sir'], useImportAPI: false }
);
test('it should throw an error when instance is not alive', async () => {
requestMock.mockImplementation(() => ({
status: 200,
data: {},
request: { connection: { servername: 'Developer instance' } },
}));
await expect(service.getIncident('1')).rejects.toThrow(
'There is an issue with your Service Now Instance. Please check Developer instance.'
);
mockIncidentResponse(false);
const res = await service.updateIncident({
incidentId: '1',
incident: { short_description: 'title', description: 'desc' } as ServiceNowITSMIncident,
});
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/v2/table/sn_si_incident/1',
method: 'patch',
data: { short_description: 'title', description: 'desc' },
});
expect(res.url).toEqual('https://example.com/nav_to.do?uri=sn_si_incident.do?sys_id=1');
});
});
});
@ -388,7 +633,7 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
});
});
@ -402,13 +647,13 @@ describe('ServiceNow service', () => {
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
'sn_si_incident',
{
config: { apiUrl: 'https://dev102283.service-now.com/' },
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
configurationUtilities,
{ ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }
);
requestMock.mockImplementation(() => ({
@ -420,7 +665,7 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
url: 'https://example.com/api/now/table/sys_dictionary?sysparm_query=name=task^ORname=sn_si_incident^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory',
});
});
@ -456,7 +701,7 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
});
});
@ -470,13 +715,13 @@ describe('ServiceNow service', () => {
test('it should call request with correct arguments when table changes', async () => {
service = createExternalService(
'sn_si_incident',
{
config: { apiUrl: 'https://dev102283.service-now.com/' },
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities
configurationUtilities,
{ ...snExternalServiceConfig['.servicenow'], table: 'sn_si_incident' }
);
requestMock.mockImplementation(() => ({
@ -489,7 +734,7 @@ describe('ServiceNow service', () => {
axios,
logger,
configurationUtilities,
url: 'https://dev102283.service-now.com/api/now/v2/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=sn_si_incident^element=priority^ORelement=category&sysparm_fields=label,value,dependent_value,element',
});
});
@ -513,4 +758,79 @@ describe('ServiceNow service', () => {
);
});
});
describe('getUrl', () => {
test('it returns the instance url', async () => {
expect(service.getUrl()).toBe('https://example.com');
});
});
describe('checkInstance', () => {
test('it throws an error if there is no result on data', () => {
const res = { status: 200, data: {} } as AxiosResponse;
expect(() => service.checkInstance(res)).toThrow();
});
test('it does NOT throws an error if the status > 400', () => {
const res = { status: 500, data: {} } as AxiosResponse;
expect(() => service.checkInstance(res)).not.toThrow();
});
test('it shows the servername', () => {
const res = {
status: 200,
data: {},
request: { connection: { servername: 'https://example.com' } },
} as AxiosResponse;
expect(() => service.checkInstance(res)).toThrow(
'There is an issue with your Service Now Instance. Please check https://example.com.'
);
});
describe('getApplicationInformation', () => {
test('it returns the application information', async () => {
mockApplicationVersion();
const res = await service.getApplicationInformation();
expect(res).toEqual({
name: 'Elastic',
scope: 'x_elas2_inc_int',
version: '1.0.0',
});
});
test('it should throw an error', async () => {
requestMock.mockImplementation(() => {
throw new Error('An error has occurred');
});
await expect(service.getApplicationInformation()).rejects.toThrow(
'[Action][ServiceNow]: Unable to get application version. Error: An error has occurred Reason: unknown'
);
});
});
describe('checkIfApplicationIsInstalled', () => {
test('it logs the application information', async () => {
mockApplicationVersion();
await service.checkIfApplicationIsInstalled();
expect(logger.debug).toHaveBeenCalledWith(
'Create incident: Application scope: x_elas2_inc_int: Application version1.0.0'
);
});
test('it does not log if useOldApi = true', async () => {
service = createExternalService(
{
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
{ ...snExternalServiceConfig['.servicenow'], useImportAPI: false }
);
await service.checkIfApplicationIsInstalled();
expect(requestMock).not.toHaveBeenCalled();
expect(logger.debug).not.toHaveBeenCalled();
});
});
});
});

View file

@ -7,28 +7,35 @@
import axios, { AxiosResponse } from 'axios';
import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from './types';
import {
ExternalServiceCredentials,
ExternalService,
ExternalServiceParamsCreate,
ExternalServiceParamsUpdate,
ImportSetApiResponse,
ImportSetApiResponseError,
ServiceNowIncident,
GetApplicationInfoResponse,
SNProductsConfigValue,
ServiceFactory,
} from './types';
import * as i18n from './translations';
import { Logger } from '../../../../../../src/core/server';
import {
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ResponseError,
} from './types';
import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios_utils';
import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types';
import { request } from '../lib/axios_utils';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { createServiceError, getPushedDate, prepareIncident } from './utils';
const API_VERSION = 'v2';
const SYS_DICTIONARY = `api/now/${API_VERSION}/table/sys_dictionary`;
export const SYS_DICTIONARY_ENDPOINT = `api/now/table/sys_dictionary`;
export const createExternalService = (
table: string,
export const createExternalService: ServiceFactory = (
{ config, secrets }: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities
configurationUtilities: ActionsConfigurationUtilities,
{ table, importSetTable, useImportAPI, appScope }: SNProductsConfigValue
): ExternalService => {
const { apiUrl: url } = config as ServiceNowPublicConfigurationType;
const { apiUrl: url, isLegacy } = config as ServiceNowPublicConfigurationType;
const { username, password } = secrets as ServiceNowSecretConfigurationType;
if (!url || !username || !password) {
@ -36,13 +43,26 @@ export const createExternalService = (
}
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
const incidentUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/${table}`;
const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`;
const choicesUrl = `${urlWithoutTrailingSlash}/api/now/${API_VERSION}/table/sys_choice`;
const importSetTableUrl = `${urlWithoutTrailingSlash}/api/now/import/${importSetTable}`;
const tableApiIncidentUrl = `${urlWithoutTrailingSlash}/api/now/v2/table/${table}`;
const fieldsUrl = `${urlWithoutTrailingSlash}/${SYS_DICTIONARY_ENDPOINT}?sysparm_query=name=task^ORname=${table}^internal_type=string&active=true&array=false&read_only=false&sysparm_fields=max_length,element,column_label,mandatory`;
const choicesUrl = `${urlWithoutTrailingSlash}/api/now/table/sys_choice`;
/**
* Need to be set the same at:
* x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/api.ts
*/
const getVersionUrl = () => `${urlWithoutTrailingSlash}/api/${appScope}/elastic_api/health`;
const axiosInstance = axios.create({
auth: { username, password },
});
const useOldApi = !useImportAPI || isLegacy;
const getCreateIncidentUrl = () => (useOldApi ? tableApiIncidentUrl : importSetTableUrl);
const getUpdateIncidentUrl = (incidentId: string) =>
useOldApi ? `${tableApiIncidentUrl}/${incidentId}` : importSetTableUrl;
const getIncidentViewURL = (id: string) => {
// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html
return `${urlWithoutTrailingSlash}/nav_to.do?uri=${table}.do?sys_id=${id}`;
@ -57,7 +77,7 @@ export const createExternalService = (
};
const checkInstance = (res: AxiosResponse) => {
if (res.status === 200 && res.data.result == null) {
if (res.status >= 200 && res.status < 400 && res.data.result == null) {
throw new Error(
`There is an issue with your Service Now Instance. Please check ${
res.request?.connection?.servername ?? ''
@ -66,34 +86,70 @@ export const createExternalService = (
}
};
const createErrorMessage = (errorResponse: ResponseError): string => {
if (errorResponse == null) {
return '';
const isImportSetApiResponseAnError = (
data: ImportSetApiResponse['result'][0]
): data is ImportSetApiResponseError['result'][0] => data.status === 'error';
const throwIfImportSetApiResponseIsAnError = (res: ImportSetApiResponse) => {
if (res.result.length === 0) {
throw new Error('Unexpected result');
}
const { error } = errorResponse;
return error != null ? `${error?.message}: ${error?.detail}` : '';
const data = res.result[0];
// Create ResponseError message?
if (isImportSetApiResponseAnError(data)) {
throw new Error(data.error_message);
}
};
const getIncident = async (id: string) => {
/**
* Gets the Elastic SN Application information including the current version.
* It should not be used on legacy connectors.
*/
const getApplicationInformation = async (): Promise<GetApplicationInfoResponse> => {
try {
const res = await request({
axios: axiosInstance,
url: `${incidentUrl}/${id}`,
url: getVersionUrl(),
logger,
configurationUtilities,
method: 'get',
});
checkInstance(res);
return { ...res.data.result };
} catch (error) {
throw new Error(
getErrorMessage(
i18n.SERVICENOW,
`Unable to get incident with id ${id}. Error: ${
error.message
} Reason: ${createErrorMessage(error.response?.data)}`
)
);
throw createServiceError(error, 'Unable to get application version');
}
};
const logApplicationInfo = (scope: string, version: string) =>
logger.debug(`Create incident: Application scope: ${scope}: Application version${version}`);
const checkIfApplicationIsInstalled = async () => {
if (!useOldApi) {
const { version, scope } = await getApplicationInformation();
logApplicationInfo(scope, version);
}
};
const getIncident = async (id: string): Promise<ServiceNowIncident> => {
try {
const res = await request({
axios: axiosInstance,
url: `${tableApiIncidentUrl}/${id}`,
logger,
configurationUtilities,
method: 'get',
});
checkInstance(res);
return { ...res.data.result };
} catch (error) {
throw createServiceError(error, `Unable to get incident with id ${id}`);
}
};
@ -101,7 +157,7 @@ export const createExternalService = (
try {
const res = await request({
axios: axiosInstance,
url: incidentUrl,
url: tableApiIncidentUrl,
logger,
params,
configurationUtilities,
@ -109,71 +165,80 @@ export const createExternalService = (
checkInstance(res);
return res.data.result.length > 0 ? { ...res.data.result } : undefined;
} catch (error) {
throw new Error(
getErrorMessage(
i18n.SERVICENOW,
`Unable to find incidents by query. Error: ${error.message} Reason: ${createErrorMessage(
error.response?.data
)}`
)
);
throw createServiceError(error, 'Unable to find incidents by query');
}
};
const createIncident = async ({ incident }: ExternalServiceParams) => {
const getUrl = () => urlWithoutTrailingSlash;
const createIncident = async ({ incident }: ExternalServiceParamsCreate) => {
try {
await checkIfApplicationIsInstalled();
const res = await request({
axios: axiosInstance,
url: `${incidentUrl}`,
url: getCreateIncidentUrl(),
logger,
method: 'post',
data: { ...(incident as Record<string, unknown>) },
data: prepareIncident(useOldApi, incident),
configurationUtilities,
});
checkInstance(res);
if (!useOldApi) {
throwIfImportSetApiResponseIsAnError(res.data);
}
const incidentId = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id;
const insertedIncident = await getIncident(incidentId);
return {
title: res.data.result.number,
id: res.data.result.sys_id,
pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_created_on)).toISOString(),
url: getIncidentViewURL(res.data.result.sys_id),
title: insertedIncident.number,
id: insertedIncident.sys_id,
pushedDate: getPushedDate(insertedIncident.sys_created_on),
url: getIncidentViewURL(insertedIncident.sys_id),
};
} catch (error) {
throw new Error(
getErrorMessage(
i18n.SERVICENOW,
`Unable to create incident. Error: ${error.message} Reason: ${createErrorMessage(
error.response?.data
)}`
)
);
throw createServiceError(error, 'Unable to create incident');
}
};
const updateIncident = async ({ incidentId, incident }: ExternalServiceParams) => {
const updateIncident = async ({ incidentId, incident }: ExternalServiceParamsUpdate) => {
try {
const res = await patch({
await checkIfApplicationIsInstalled();
const res = await request({
axios: axiosInstance,
url: `${incidentUrl}/${incidentId}`,
url: getUpdateIncidentUrl(incidentId),
// Import Set API supports only POST.
method: useOldApi ? 'patch' : 'post',
logger,
data: { ...(incident as Record<string, unknown>) },
data: {
...prepareIncident(useOldApi, incident),
// elastic_incident_id is used to update the incident when using the Import Set API.
...(useOldApi ? {} : { elastic_incident_id: incidentId }),
},
configurationUtilities,
});
checkInstance(res);
if (!useOldApi) {
throwIfImportSetApiResponseIsAnError(res.data);
}
const id = useOldApi ? res.data.result.sys_id : res.data.result[0].sys_id;
const updatedIncident = await getIncident(id);
return {
title: res.data.result.number,
id: res.data.result.sys_id,
pushedDate: new Date(addTimeZoneToDate(res.data.result.sys_updated_on)).toISOString(),
url: getIncidentViewURL(res.data.result.sys_id),
title: updatedIncident.number,
id: updatedIncident.sys_id,
pushedDate: getPushedDate(updatedIncident.sys_updated_on),
url: getIncidentViewURL(updatedIncident.sys_id),
};
} catch (error) {
throw new Error(
getErrorMessage(
i18n.SERVICENOW,
`Unable to update incident with id ${incidentId}. Error: ${
error.message
} Reason: ${createErrorMessage(error.response?.data)}`
)
);
throw createServiceError(error, `Unable to update incident with id ${incidentId}`);
}
};
@ -185,17 +250,12 @@ export const createExternalService = (
logger,
configurationUtilities,
});
checkInstance(res);
return res.data.result.length > 0 ? res.data.result : [];
} catch (error) {
throw new Error(
getErrorMessage(
i18n.SERVICENOW,
`Unable to get fields. Error: ${error.message} Reason: ${createErrorMessage(
error.response?.data
)}`
)
);
throw createServiceError(error, 'Unable to get fields');
}
};
@ -210,14 +270,7 @@ export const createExternalService = (
checkInstance(res);
return res.data.result;
} catch (error) {
throw new Error(
getErrorMessage(
i18n.SERVICENOW,
`Unable to get choices. Error: ${error.message} Reason: ${createErrorMessage(
error.response?.data
)}`
)
);
throw createServiceError(error, 'Unable to get choices');
}
};
@ -228,5 +281,9 @@ export const createExternalService = (
getIncident,
updateIncident,
getChoices,
getUrl,
checkInstance,
getApplicationInformation,
checkIfApplicationIsInstalled,
};
};

View file

@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import axios from 'axios';
import { createExternalServiceSIR } from './service_sir';
import * as utils from '../lib/axios_utils';
import { ExternalServiceSIR } from './types';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { actionsConfigMock } from '../../actions_config.mock';
import { observables } from './mocks';
import { snExternalServiceConfig } from './config';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
jest.mock('../lib/axios_utils', () => {
const originalUtils = jest.requireActual('../lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
patch: jest.fn(),
};
});
axios.create = jest.fn(() => axios);
const requestMock = utils.request as jest.Mock;
const configurationUtilities = actionsConfigMock.create();
const mockApplicationVersion = () =>
requestMock.mockImplementationOnce(() => ({
data: {
result: { name: 'Elastic', scope: 'x_elas2_sir_int', version: '1.0.0' },
},
}));
const getAddObservablesResponse = () => [
{
value: '5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9',
observable_sys_id: '1',
},
{
value: '127.0.0.1',
observable_sys_id: '2',
},
{
value: 'https://example.com',
observable_sys_id: '3',
},
];
const mockAddObservablesResponse = (single: boolean) => {
const res = getAddObservablesResponse();
requestMock.mockImplementation(() => ({
data: {
result: single ? res[0] : res,
},
}));
};
const expectAddObservables = (single: boolean) => {
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/x_elas2_sir_int/elastic_api/health',
method: 'get',
});
const url = single
? 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables'
: 'https://example.com/api/x_elas2_sir_int/elastic_api/incident/incident-1/observables/bulk';
const data = single ? observables[0] : observables;
expect(requestMock).toHaveBeenNthCalledWith(2, {
axios,
logger,
configurationUtilities,
url,
method: 'post',
data,
});
};
describe('ServiceNow SIR service', () => {
let service: ExternalServiceSIR;
beforeEach(() => {
service = createExternalServiceSIR(
{
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
snExternalServiceConfig['.servicenow-sir']
) as ExternalServiceSIR;
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('bulkAddObservableToIncident', () => {
test('it adds multiple observables correctly', async () => {
mockApplicationVersion();
mockAddObservablesResponse(false);
const res = await service.bulkAddObservableToIncident(observables, 'incident-1');
expect(res).toEqual(getAddObservablesResponse());
expectAddObservables(false);
});
test('it adds a single observable correctly', async () => {
mockApplicationVersion();
mockAddObservablesResponse(true);
const res = await service.addObservableToIncident(observables[0], 'incident-1');
expect(res).toEqual(getAddObservablesResponse()[0]);
expectAddObservables(true);
});
});
});

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import axios from 'axios';
import {
ExternalServiceCredentials,
SNProductsConfigValue,
Observable,
ExternalServiceSIR,
ObservableResponse,
ServiceFactory,
} from './types';
import { Logger } from '../../../../../../src/core/server';
import { ServiceNowSecretConfigurationType } from './types';
import { request } from '../lib/axios_utils';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { createExternalService } from './service';
import { createServiceError } from './utils';
const getAddObservableToIncidentURL = (url: string, incidentID: string) =>
`${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables`;
const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) =>
`${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`;
export const createExternalServiceSIR: ServiceFactory = (
credentials: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities,
serviceConfig: SNProductsConfigValue
): ExternalServiceSIR => {
const snService = createExternalService(
credentials,
logger,
configurationUtilities,
serviceConfig
);
const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType;
const axiosInstance = axios.create({
auth: { username, password },
});
const _addObservable = async (data: Observable | Observable[], url: string) => {
snService.checkIfApplicationIsInstalled();
const res = await request({
axios: axiosInstance,
url,
logger,
method: 'post',
data,
configurationUtilities,
});
snService.checkInstance(res);
return res.data.result;
};
const addObservableToIncident = async (
observable: Observable,
incidentID: string
): Promise<ObservableResponse> => {
try {
return await _addObservable(
observable,
getAddObservableToIncidentURL(snService.getUrl(), incidentID)
);
} catch (error) {
throw createServiceError(
error,
`Unable to add observable to security incident with id ${incidentID}`
);
}
};
const bulkAddObservableToIncident = async (
observables: Observable[],
incidentID: string
): Promise<ObservableResponse[]> => {
try {
return await _addObservable(
observables,
getBulkAddObservableToIncidentURL(snService.getUrl(), incidentID)
);
} catch (error) {
throw createServiceError(
error,
`Unable to add observables to security incident with id ${incidentID}`
);
}
};
return {
...snService,
addObservableToIncident,
bulkAddObservableToIncident,
};
};

View file

@ -7,6 +7,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AxiosError, AxiosResponse } from 'axios';
import { TypeOf } from '@kbn/config-schema';
import {
ExecutorParamsSchemaITSM,
@ -78,15 +79,29 @@ export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
comments?: ExternalServiceCommentResponse[];
}
export type ExternalServiceParams = Record<string, unknown>;
export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident;
export type PartialIncident = Partial<Incident>;
export interface ExternalServiceParamsCreate {
incident: Incident & Record<string, unknown>;
}
export interface ExternalServiceParamsUpdate {
incidentId: string;
incident: PartialIncident & Record<string, unknown>;
}
export interface ExternalService {
getChoices: (fields: string[]) => Promise<GetChoicesResponse>;
getIncident: (id: string) => Promise<ExternalServiceParams | undefined>;
getIncident: (id: string) => Promise<ServiceNowIncident>;
getFields: () => Promise<GetCommonFieldsResponse>;
createIncident: (params: ExternalServiceParams) => Promise<ExternalServiceIncidentResponse>;
updateIncident: (params: ExternalServiceParams) => Promise<ExternalServiceIncidentResponse>;
findIncidents: (params?: Record<string, string>) => Promise<ExternalServiceParams[] | undefined>;
createIncident: (params: ExternalServiceParamsCreate) => Promise<ExternalServiceIncidentResponse>;
updateIncident: (params: ExternalServiceParamsUpdate) => Promise<ExternalServiceIncidentResponse>;
findIncidents: (params?: Record<string, string>) => Promise<ServiceNowIncident>;
getUrl: () => string;
checkInstance: (res: AxiosResponse) => void;
getApplicationInformation: () => Promise<GetApplicationInfoResponse>;
checkIfApplicationIsInstalled: () => Promise<void>;
}
export type PushToServiceApiParams = ExecutorSubActionPushParams;
@ -115,10 +130,9 @@ export type ServiceNowSIRIncident = Omit<
'externalId'
>;
export type Incident = ServiceNowITSMIncident | ServiceNowSIRIncident;
export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs {
params: PushToServiceApiParams;
config: Record<string, unknown>;
secrets: Record<string, unknown>;
logger: Logger;
commentFieldKey: string;
@ -158,12 +172,20 @@ export interface GetChoicesHandlerArgs {
params: ExecutorSubActionGetChoicesParams;
}
export interface ExternalServiceApi {
export interface ServiceNowIncident {
sys_id: string;
number: string;
sys_created_on: string;
sys_updated_on: string;
[x: string]: unknown;
}
export interface ExternalServiceAPI {
getChoices: (args: GetChoicesHandlerArgs) => Promise<GetChoicesResponse>;
getFields: (args: GetCommonFieldsHandlerArgs) => Promise<GetCommonFieldsResponse>;
handshake: (args: HandshakeApiHandlerArgs) => Promise<void>;
pushToService: (args: PushToServiceApiHandlerArgs) => Promise<PushToServiceResponse>;
getIncident: (args: GetIncidentApiHandlerArgs) => Promise<void>;
getIncident: (args: GetIncidentApiHandlerArgs) => Promise<ServiceNowIncident>;
}
export interface ExternalServiceCommentResponse {
@ -173,10 +195,90 @@ export interface ExternalServiceCommentResponse {
}
type TypeNullOrUndefined<T> = T | null | undefined;
export interface ResponseError {
export interface ServiceNowError {
error: TypeNullOrUndefined<{
message: TypeNullOrUndefined<string>;
detail: TypeNullOrUndefined<string>;
}>;
status: TypeNullOrUndefined<string>;
}
export type ResponseError = AxiosError<ServiceNowError>;
export interface ImportSetApiResponseSuccess {
import_set: string;
staging_table: string;
result: Array<{
display_name: string;
display_value: string;
record_link: string;
status: string;
sys_id: string;
table: string;
transform_map: string;
}>;
}
export interface ImportSetApiResponseError {
import_set: string;
staging_table: string;
result: Array<{
error_message: string;
status_message: string;
status: string;
transform_map: string;
}>;
}
export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError;
export interface GetApplicationInfoResponse {
id: string;
name: string;
scope: string;
version: string;
}
export interface SNProductsConfigValue {
table: string;
appScope: string;
useImportAPI: boolean;
importSetTable: string;
commentFieldKey: string;
}
export type SNProductsConfig = Record<string, SNProductsConfigValue>;
export enum ObservableTypes {
ip4 = 'ipv4-addr',
url = 'URL',
sha256 = 'SHA256',
}
export interface Observable {
value: string;
type: ObservableTypes;
}
export interface ObservableResponse {
value: string;
observable_sys_id: ObservableTypes;
}
export interface ExternalServiceSIR extends ExternalService {
addObservableToIncident: (
observable: Observable,
incidentID: string
) => Promise<ObservableResponse>;
bulkAddObservableToIncident: (
observables: Observable[],
incidentID: string
) => Promise<ObservableResponse[]>;
}
export type ServiceFactory = (
credentials: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities,
serviceConfig: SNProductsConfigValue
) => ExternalServiceSIR | ExternalService;

View file

@ -0,0 +1,84 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { AxiosError } from 'axios';
import { prepareIncident, createServiceError, getPushedDate } from './utils';
/**
* The purpose of this test is to
* prevent developers from accidentally
* change important configuration values
* such as the scope or the import set table
* of our ServiceNow application
*/
describe('utils', () => {
describe('prepareIncident', () => {
test('it prepares the incident correctly when useOldApi=false', async () => {
const incident = { short_description: 'title', description: 'desc' };
const newIncident = prepareIncident(false, incident);
expect(newIncident).toEqual({ u_short_description: 'title', u_description: 'desc' });
});
test('it prepares the incident correctly when useOldApi=true', async () => {
const incident = { short_description: 'title', description: 'desc' };
const newIncident = prepareIncident(true, incident);
expect(newIncident).toEqual(incident);
});
});
describe('createServiceError', () => {
test('it creates an error when the response is null', async () => {
const error = new Error('An error occurred');
// @ts-expect-error
expect(createServiceError(error, 'Unable to do action').message).toBe(
'[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: errorResponse was null'
);
});
test('it creates an error with response correctly', async () => {
const axiosError = {
message: 'An error occurred',
response: { data: { error: { message: 'Denied', detail: 'no access' } } },
} as AxiosError;
expect(createServiceError(axiosError, 'Unable to do action').message).toBe(
'[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: Denied: no access'
);
});
test('it creates an error correctly when the ServiceNow error is null', async () => {
const axiosError = {
message: 'An error occurred',
response: { data: { error: null } },
} as AxiosError;
expect(createServiceError(axiosError, 'Unable to do action').message).toBe(
'[Action][ServiceNow]: Unable to do action. Error: An error occurred Reason: unknown: no error in error response'
);
});
});
describe('getPushedDate', () => {
beforeAll(() => {
jest.useFakeTimers('modern');
jest.setSystemTime(new Date('2021-10-04 11:15:06 GMT'));
});
afterAll(() => {
jest.useRealTimers();
});
test('it formats the date correctly if timestamp is provided', async () => {
expect(getPushedDate('2021-10-04 11:15:06')).toBe('2021-10-04T11:15:06.000Z');
});
test('it formats the date correctly if timestamp is not provided', async () => {
expect(getPushedDate()).toBe('2021-10-04T11:15:06.000Z');
});
});
});

View file

@ -0,0 +1,46 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Incident, PartialIncident, ResponseError, ServiceNowError } from './types';
import { FIELD_PREFIX } from './config';
import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils';
import * as i18n from './translations';
export const prepareIncident = (useOldApi: boolean, incident: PartialIncident): PartialIncident =>
useOldApi
? incident
: Object.entries(incident).reduce(
(acc, [key, value]) => ({ ...acc, [`${FIELD_PREFIX}${key}`]: value }),
{} as Incident
);
const createErrorMessage = (errorResponse?: ServiceNowError): string => {
if (errorResponse == null) {
return 'unknown: errorResponse was null';
}
const { error } = errorResponse;
return error != null
? `${error?.message}: ${error?.detail}`
: 'unknown: no error in error response';
};
export const createServiceError = (error: ResponseError, message: string) =>
new Error(
getErrorMessage(
i18n.SERVICENOW,
`${message}. Error: ${error.message} Reason: ${createErrorMessage(error.response?.data)}`
)
);
export const getPushedDate = (timestamp?: string) => {
if (timestamp != null) {
return new Date(addTimeZoneToDate(timestamp)).toISOString();
}
return new Date().toISOString();
};

View file

@ -0,0 +1,12 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// TODO: Remove when Elastic for ITSM is published.
export const ENABLE_NEW_SN_ITSM_CONNECTOR = true;
// TODO: Remove when Elastic for Security Operations is published.
export const ENABLE_NEW_SN_SIR_CONNECTOR = true;

View file

@ -165,6 +165,47 @@ describe('successful migrations', () => {
});
expect(migratedAction).toEqual(action);
});
test('set isLegacy config property for .servicenow', () => {
const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
const action = getMockDataForServiceNow();
const migratedAction = migration716(action, context);
expect(migratedAction).toEqual({
...action,
attributes: {
...action.attributes,
config: {
apiUrl: 'https://example.com',
isLegacy: true,
},
},
});
});
test('set isLegacy config property for .servicenow-sir', () => {
const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
const action = getMockDataForServiceNow({ actionTypeId: '.servicenow-sir' });
const migratedAction = migration716(action, context);
expect(migratedAction).toEqual({
...action,
attributes: {
...action.attributes,
config: {
apiUrl: 'https://example.com',
isLegacy: true,
},
},
});
});
test('it does not set isLegacy config for other connectors', () => {
const migration716 = getActionsMigrations(encryptedSavedObjectsSetup)['7.16.0'];
const action = getMockData();
const migratedAction = migration716(action, context);
expect(migratedAction).toEqual(action);
});
});
describe('8.0.0', () => {
@ -306,3 +347,19 @@ function getMockData(
type: 'action',
};
}
function getMockDataForServiceNow(
overwrites: Record<string, unknown> = {}
): SavedObjectUnsanitizedDoc<Omit<RawAction, 'isMissingSecrets'>> {
return {
attributes: {
name: 'abc',
actionTypeId: '.servicenow',
config: { apiUrl: 'https://example.com' },
secrets: { user: 'test', password: '123' },
...overwrites,
},
id: uuid.v4(),
type: 'action',
};
}

View file

@ -59,13 +59,16 @@ export function getActionsMigrations(
const migrationActionsFourteen = createEsoMigration(
encryptedSavedObjects,
(doc): doc is SavedObjectUnsanitizedDoc<RawAction> => true,
pipeMigrations(addisMissingSecretsField)
pipeMigrations(addIsMissingSecretsField)
);
const migrationEmailActionsSixteen = createEsoMigration(
const migrationActionsSixteen = createEsoMigration(
encryptedSavedObjects,
(doc): doc is SavedObjectUnsanitizedDoc<RawAction> => doc.attributes.actionTypeId === '.email',
pipeMigrations(setServiceConfigIfNotSet)
(doc): doc is SavedObjectUnsanitizedDoc<RawAction> =>
doc.attributes.actionTypeId === '.servicenow' ||
doc.attributes.actionTypeId === '.servicenow-sir' ||
doc.attributes.actionTypeId === '.email',
pipeMigrations(markOldServiceNowITSMConnectorAsLegacy, setServiceConfigIfNotSet)
);
const migrationActions800 = createEsoMigration(
@ -79,7 +82,7 @@ export function getActionsMigrations(
'7.10.0': executeMigrationWithErrorHandling(migrationActionsTen, '7.10.0'),
'7.11.0': executeMigrationWithErrorHandling(migrationActionsEleven, '7.11.0'),
'7.14.0': executeMigrationWithErrorHandling(migrationActionsFourteen, '7.14.0'),
'7.16.0': executeMigrationWithErrorHandling(migrationEmailActionsSixteen, '7.16.0'),
'7.16.0': executeMigrationWithErrorHandling(migrationActionsSixteen, '7.16.0'),
'8.0.0': executeMigrationWithErrorHandling(migrationActions800, '8.0.0'),
};
}
@ -182,7 +185,7 @@ const setServiceConfigIfNotSet = (
};
};
const addisMissingSecretsField = (
const addIsMissingSecretsField = (
doc: SavedObjectUnsanitizedDoc<RawAction>
): SavedObjectUnsanitizedDoc<RawAction> => {
return {
@ -194,6 +197,28 @@ const addisMissingSecretsField = (
};
};
const markOldServiceNowITSMConnectorAsLegacy = (
doc: SavedObjectUnsanitizedDoc<RawAction>
): SavedObjectUnsanitizedDoc<RawAction> => {
if (
doc.attributes.actionTypeId !== '.servicenow' &&
doc.attributes.actionTypeId !== '.servicenow-sir'
) {
return doc;
}
return {
...doc,
attributes: {
...doc.attributes,
config: {
...doc.attributes.config,
isLegacy: true,
},
},
};
};
function pipeMigrations(...migrations: ActionMigration[]): ActionMigration {
return (doc: SavedObjectUnsanitizedDoc<RawAction>) =>
migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc);

View file

@ -1,9 +1,9 @@
Case management in Kibana
# Case management in Kibana
[![Issues][issues-shield]][issues-url]
[![Pull Requests][pr-shield]][pr-url]
[![Pull Requests][pr-shield]][pr-url]
# Cases Plugin Docs
# Docs
![Cases Logo][cases-logo]
@ -288,9 +288,9 @@ Connectors of type (`.none`) should have the `fields` attribute set to `null`.
<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[pr-shield]: https://img.shields.io/github/issues-pr/elangosundar/awesome-README-templates?style=for-the-badge
[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+label%3AFeature%3ACases+-is%3Adraft+is%3Aopen+
[issues-shield]: https://img.shields.io/github/issues/othneildrew/Best-README-Template.svg?style=for-the-badge
[pr-shield]: https://img.shields.io/github/issues-pr/elastic/kibana/Team:Threat%20Hunting:Cases?label=pull%20requests&style=for-the-badge
[pr-url]: https://github.com/elastic/kibana/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc+label%3A%22Team%3AThreat+Hunting%3ACases%22
[issues-shield]: https://img.shields.io/github/issues-search?label=issue&query=repo%3Aelastic%2Fkibana%20is%3Aissue%20is%3Aopen%20label%3A%22Team%3AThreat%20Hunting%3ACases%22&style=for-the-badge
[issues-url]: https://github.com/elastic/kibana/issues?q=is%3Aopen+is%3Aissue+label%3AFeature%3ACases
[cases-logo]: images/logo.png
[configure-img]: images/configure.png

View file

@ -16,6 +16,7 @@ import {
User,
UserAction,
UserActionField,
ActionConnector,
} from '../api';
export interface CasesUiConfigType {
@ -259,3 +260,5 @@ export interface Ecs {
_index?: string;
signal?: SignalEcs;
}
export type CaseActionConnector = ActionConnector;

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TriggersAndActionsUIPublicPluginStart } from '../../../../triggers_actions_ui/public';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
import { CaseActionConnector } from '../../../common';
const getUniqueActionTypeIds = (connectors: CaseActionConnector[]) =>
new Set(connectors.map((connector) => connector.actionTypeId));
export const registerConnectorsToMockActionRegistry = (
actionTypeRegistry: TriggersAndActionsUIPublicPluginStart['actionTypeRegistry'],
connectors: CaseActionConnector[]
) => {
const { createMockActionTypeModel } = actionTypeRegistryMock;
const uniqueActionTypeIds = getUniqueActionTypeIds(connectors);
uniqueActionTypeIds.forEach((actionTypeId) =>
actionTypeRegistry.register(
createMockActionTypeModel({ id: actionTypeId, iconClass: 'logoSecurity' })
)
);
};

View file

@ -19,8 +19,8 @@ import { useKibana } from '../../common/lib/kibana';
import { StatusAll } from '../../containers/types';
import { CaseStatuses, SECURITY_SOLUTION_OWNER } from '../../../common';
import { connectorsMock } from '../../containers/mock';
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks';
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
jest.mock('../../containers/use_get_reporters');
jest.mock('../../containers/use_get_tags');
@ -59,14 +59,10 @@ jest.mock('../../common/lib/kibana', () => {
});
describe('AllCasesGeneric ', () => {
const { createMockActionTypeModel } = actionTypeRegistryMock;
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
beforeAll(() => {
connectorsMock.forEach((connector) =>
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
)
);
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock);
});
beforeEach(() => {

View file

@ -12,21 +12,17 @@ import '../../common/mock/match_media';
import { ExternalServiceColumn } from './columns';
import { useGetCasesMockState } from '../../containers/mock';
import { useKibana } from '../../common/lib/kibana';
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
import { connectors } from '../configure_cases/__mock__';
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
jest.mock('../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
describe('ExternalServiceColumn ', () => {
const { createMockActionTypeModel } = actionTypeRegistryMock;
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
beforeAll(() => {
connectors.forEach((connector) =>
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
)
);
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors);
});
it('Not pushed render', () => {

View file

@ -32,8 +32,8 @@ import { useKibana } from '../../common/lib/kibana';
import { AllCasesGeneric as AllCases } from './all_cases_generic';
import { AllCasesProps } from '.';
import { CasesColumns, GetCasesColumn, useCasesColumns } from './columns';
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks';
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
jest.mock('../../containers/use_bulk_update_case');
jest.mock('../../containers/use_delete_cases');
@ -148,14 +148,10 @@ describe('AllCasesGeneric', () => {
userCanCrud: true,
};
const { createMockActionTypeModel } = actionTypeRegistryMock;
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
beforeAll(() => {
connectorsMock.forEach((connector) =>
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
)
);
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock);
});
beforeEach(() => {

View file

@ -7,6 +7,7 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { render, screen } from '@testing-library/react';
import { Connectors, Props } from './connectors';
import { TestProviders } from '../../common/mock';
@ -14,6 +15,7 @@ import { ConnectorsDropdown } from './connectors_dropdown';
import { connectors, actionTypes } from './__mock__';
import { ConnectorTypes } from '../../../common';
import { useKibana } from '../../common/lib/kibana';
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
jest.mock('../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
@ -35,11 +37,10 @@ describe('Connectors', () => {
updateConnectorDisabled: false,
};
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
beforeAll(() => {
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.get = jest.fn().mockReturnValue({
actionTypeTitle: 'test',
iconClass: 'logoSecurity',
});
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors);
wrapper = mount(<Connectors {...props} />, { wrappingComponent: TestProviders });
});
@ -121,4 +122,33 @@ describe('Connectors', () => {
.text()
).toBe('Update My Connector');
});
test('it shows the deprecated callout when the connector is legacy', async () => {
render(
<Connectors
{...props}
selectedConnector={{ id: 'servicenow-legacy', type: ConnectorTypes.serviceNowITSM }}
/>,
{
// wrapper: TestProviders produces a TS error
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
}
);
expect(screen.getByText('Deprecated connector type')).toBeInTheDocument();
expect(
screen.getByText(
'This connector type is deprecated. Create a new connector or update this connector'
)
).toBeInTheDocument();
});
test('it does not shows the deprecated callout when the connector is none', async () => {
render(<Connectors {...props} />, {
// wrapper: TestProviders produces a TS error
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
expect(screen.queryByText('Deprecated connector type')).not.toBeInTheDocument();
});
});

View file

@ -22,6 +22,8 @@ import * as i18n from './translations';
import { ActionConnector, CaseConnectorMapping } from '../../containers/configure/types';
import { Mapping } from './mapping';
import { ActionTypeConnector, ConnectorTypes } from '../../../common';
import { DeprecatedCallout } from '../connectors/deprecated_callout';
import { isLegacyConnector } from '../utils';
const EuiFormRowExtended = styled(EuiFormRow)`
.euiFormRow__labelWrapper {
@ -53,11 +55,13 @@ const ConnectorsComponent: React.FC<Props> = ({
selectedConnector,
updateConnectorDisabled,
}) => {
const connectorsName = useMemo(
() => connectors.find((c) => c.id === selectedConnector.id)?.name ?? 'none',
const connector = useMemo(
() => connectors.find((c) => c.id === selectedConnector.id),
[connectors, selectedConnector.id]
);
const connectorsName = connector?.name ?? 'none';
const actionTypeName = useMemo(
() => actionTypes.find((c) => c.id === selectedConnector.type)?.name ?? 'Unknown',
[actionTypes, selectedConnector.type]
@ -107,6 +111,11 @@ const ConnectorsComponent: React.FC<Props> = ({
appendAddConnectorButton={true}
/>
</EuiFlexItem>
{selectedConnector.type !== ConnectorTypes.none && isLegacyConnector(connector) && (
<EuiFlexItem grow={false}>
<DeprecatedCallout />
</EuiFlexItem>
)}
{selectedConnector.type !== ConnectorTypes.none ? (
<EuiFlexItem grow={false}>
<Mapping

View file

@ -8,12 +8,13 @@
import React from 'react';
import { mount, ReactWrapper } from 'enzyme';
import { EuiSuperSelect } from '@elastic/eui';
import { render, screen } from '@testing-library/react';
import { ConnectorsDropdown, Props } from './connectors_dropdown';
import { TestProviders } from '../../common/mock';
import { connectors } from './__mock__';
import { useKibana } from '../../common/lib/kibana';
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
jest.mock('../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
@ -28,14 +29,10 @@ describe('ConnectorsDropdown', () => {
selectedConnector: 'none',
};
const { createMockActionTypeModel } = actionTypeRegistryMock;
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
beforeAll(() => {
connectors.forEach((connector) =>
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
)
);
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors);
wrapper = mount(<ConnectorsDropdown {...props} />, { wrappingComponent: TestProviders });
});
@ -77,7 +74,7 @@ describe('ConnectorsDropdown', () => {
"data-test-subj": "dropdown-connector-servicenow-1",
"inputDisplay": <EuiFlexGroup
alignItems="center"
gutterSize="none"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
@ -88,7 +85,9 @@ describe('ConnectorsDropdown', () => {
type="logoSecurity"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem
grow={false}
>
<span>
My Connector
</span>
@ -100,7 +99,7 @@ describe('ConnectorsDropdown', () => {
"data-test-subj": "dropdown-connector-resilient-2",
"inputDisplay": <EuiFlexGroup
alignItems="center"
gutterSize="none"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
@ -111,7 +110,9 @@ describe('ConnectorsDropdown', () => {
type="logoSecurity"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem
grow={false}
>
<span>
My Connector 2
</span>
@ -123,7 +124,7 @@ describe('ConnectorsDropdown', () => {
"data-test-subj": "dropdown-connector-jira-1",
"inputDisplay": <EuiFlexGroup
alignItems="center"
gutterSize="none"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
@ -134,7 +135,9 @@ describe('ConnectorsDropdown', () => {
type="logoSecurity"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem
grow={false}
>
<span>
Jira
</span>
@ -146,7 +149,7 @@ describe('ConnectorsDropdown', () => {
"data-test-subj": "dropdown-connector-servicenow-sir",
"inputDisplay": <EuiFlexGroup
alignItems="center"
gutterSize="none"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
@ -157,7 +160,9 @@ describe('ConnectorsDropdown', () => {
type="logoSecurity"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem
grow={false}
>
<span>
My Connector SIR
</span>
@ -165,6 +170,43 @@ describe('ConnectorsDropdown', () => {
</EuiFlexGroup>,
"value": "servicenow-sir",
},
Object {
"data-test-subj": "dropdown-connector-servicenow-legacy",
"inputDisplay": <EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
grow={false}
>
<Styled(EuiIcon)
size="m"
type="logoSecurity"
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<span>
My Connector
</span>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<EuiIconTip
aria-label="Deprecated connector"
color="warning"
content="Please update your connector"
size="m"
title="Deprecated connector"
type="alert"
/>
</EuiFlexItem>
</EuiFlexGroup>,
"value": "servicenow-legacy",
},
]
`);
});
@ -245,4 +287,13 @@ describe('ConnectorsDropdown', () => {
)
).not.toThrowError();
});
test('it shows the deprecated tooltip when the connector is legacy', () => {
render(<ConnectorsDropdown {...props} selectedConnector="servicenow-legacy" />, {
wrapper: ({ children }) => <TestProviders>{children}</TestProviders>,
});
const tooltips = screen.getAllByLabelText('Deprecated connector');
expect(tooltips[0]).toBeInTheDocument();
});
});

View file

@ -6,14 +6,14 @@
*/
import React, { useMemo } from 'react';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect } from '@elastic/eui';
import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiIconTip, EuiSuperSelect } from '@elastic/eui';
import styled from 'styled-components';
import { ConnectorTypes } from '../../../common';
import { ActionConnector } from '../../containers/configure/types';
import * as i18n from './translations';
import { useKibana } from '../../common/lib/kibana';
import { getConnectorIcon } from '../utils';
import { getConnectorIcon, isLegacyConnector } from '../utils';
export interface Props {
connectors: ActionConnector[];
@ -79,16 +79,28 @@ const ConnectorsDropdownComponent: React.FC<Props> = ({
{
value: connector.id,
inputDisplay: (
<EuiFlexGroup gutterSize="none" alignItems="center" responsive={false}>
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<EuiIconExtended
type={getConnectorIcon(triggersActionsUi, connector.actionTypeId)}
size={ICON_SIZE}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexItem grow={false}>
<span>{connector.name}</span>
</EuiFlexItem>
{isLegacyConnector(connector) && (
<EuiFlexItem grow={false}>
<EuiIconTip
aria-label={i18n.DEPRECATED_TOOLTIP_TITLE}
size={ICON_SIZE}
type="alert"
color="warning"
title={i18n.DEPRECATED_TOOLTIP_TITLE}
content={i18n.DEPRECATED_TOOLTIP_CONTENT}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
),
'data-test-subj': `dropdown-connector-${connector.id}`,

View file

@ -162,3 +162,17 @@ export const UPDATE_SELECTED_CONNECTOR = (connectorName: string): string =>
values: { connectorName },
defaultMessage: 'Update { connectorName }',
});
export const DEPRECATED_TOOLTIP_TITLE = i18n.translate(
'xpack.cases.configureCases.deprecatedTooltipTitle',
{
defaultMessage: 'Deprecated connector',
}
);
export const DEPRECATED_TOOLTIP_CONTENT = i18n.translate(
'xpack.cases.configureCases.deprecatedTooltipContent',
{
defaultMessage: 'Please update your connector',
}
);

View file

@ -10,22 +10,18 @@ import { mount } from 'enzyme';
import { ConnectorTypes } from '../../../common';
import { useKibana } from '../../common/lib/kibana';
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
import { connectors } from '../configure_cases/__mock__';
import { ConnectorCard } from './card';
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
jest.mock('../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
describe('ConnectorCard ', () => {
const { createMockActionTypeModel } = actionTypeRegistryMock;
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
beforeAll(() => {
connectors.forEach((connector) =>
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
)
);
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectors);
});
it('it does not throw when accessing the icon if the connector type is not registered', () => {

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { DeprecatedCallout } from './deprecated_callout';
describe('DeprecatedCallout', () => {
test('it renders correctly', () => {
render(<DeprecatedCallout />);
expect(screen.getByText('Deprecated connector type')).toBeInTheDocument();
expect(
screen.getByText(
'This connector type is deprecated. Create a new connector or update this connector'
)
).toBeInTheDocument();
expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass(
'euiCallOut euiCallOut--warning'
);
});
test('it renders a danger flyout correctly', () => {
render(<DeprecatedCallout type="danger" />);
expect(screen.getByTestId('legacy-connector-warning-callout')).toHaveClass(
'euiCallOut euiCallOut--danger'
);
});
});

View file

@ -0,0 +1,42 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiCallOut, EuiCallOutProps } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
const LEGACY_CONNECTOR_WARNING_TITLE = i18n.translate(
'xpack.cases.connectors.serviceNow.legacyConnectorWarningTitle',
{
defaultMessage: 'Deprecated connector type',
}
);
const LEGACY_CONNECTOR_WARNING_DESC = i18n.translate(
'xpack.cases.connectors.serviceNow.legacyConnectorWarningDesc',
{
defaultMessage:
'This connector type is deprecated. Create a new connector or update this connector',
}
);
interface Props {
type?: EuiCallOutProps['color'];
}
const DeprecatedCalloutComponent: React.FC<Props> = ({ type = 'warning' }) => (
<EuiCallOut
title={LEGACY_CONNECTOR_WARNING_TITLE}
color={type}
iconType="alert"
data-test-subj="legacy-connector-warning-callout"
>
{LEGACY_CONNECTOR_WARNING_DESC}
</EuiCallOut>
);
export const DeprecatedCallout = React.memo(DeprecatedCalloutComponent);

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { waitFor, act } from '@testing-library/react';
import { waitFor, act, render, screen } from '@testing-library/react';
import { EuiSelect } from '@elastic/eui';
import { mount } from 'enzyme';
@ -127,6 +127,17 @@ describe('ServiceNowITSM Fields', () => {
);
});
test('it shows the deprecated callout when the connector is legacy', async () => {
const legacyConnector = { ...connector, config: { isLegacy: true } };
render(<Fields fields={fields} onChange={onChange} connector={legacyConnector} />);
expect(screen.getByTestId('legacy-connector-warning-callout')).toBeInTheDocument();
});
test('it does not show the deprecated callout when the connector is not legacy', async () => {
render(<Fields fields={fields} onChange={onChange} connector={connector} />);
expect(screen.queryByTestId('legacy-connector-warning-callout')).not.toBeInTheDocument();
});
describe('onChange calls', () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);

View file

@ -16,6 +16,8 @@ import { ConnectorCard } from '../card';
import { useGetChoices } from './use_get_choices';
import { Fields, Choice } from './types';
import { choicesToEuiOptions } from './helpers';
import { connectorValidator } from './validator';
import { DeprecatedCallout } from '../deprecated_callout';
const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory'];
const defaultFields: Fields = {
@ -39,6 +41,7 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
} = fields ?? {};
const { http, notifications } = useKibana().services;
const [choices, setChoices] = useState<Fields>(defaultFields);
const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]);
const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]);
const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]);
@ -149,90 +152,111 @@ const ServiceNowITSMFieldsComponent: React.FunctionComponent<
}
}, [category, impact, onChange, severity, subcategory, urgency]);
return isEdit ? (
<div data-test-subj={'connector-fields-sn-itsm'}>
<EuiFormRow fullWidth label={i18n.URGENCY}>
<EuiSelect
fullWidth
data-test-subj="urgencySelect"
options={urgencyOptions}
value={urgency ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('urgency', e.target.value)}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.SEVERITY}>
<EuiSelect
fullWidth
data-test-subj="severitySelect"
options={severityOptions}
value={severity ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('severity', e.target.value)}
return (
<>
{showConnectorWarning && (
<EuiFlexGroup>
<EuiFlexItem>
<DeprecatedCallout type={isEdit ? 'danger' : 'warning'} />
</EuiFlexItem>
</EuiFlexGroup>
)}
{isEdit ? (
<div data-test-subj="connector-fields-sn-itsm">
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.URGENCY}>
<EuiSelect
fullWidth
data-test-subj="urgencySelect"
options={urgencyOptions}
value={urgency ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('urgency', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.SEVERITY}>
<EuiSelect
fullWidth
data-test-subj="severitySelect"
options={severityOptions}
value={severity ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('severity', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.IMPACT}>
<EuiSelect
fullWidth
data-test-subj="impactSelect"
options={impactOptions}
value={impact ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('impact', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CATEGORY}>
<EuiSelect
fullWidth
data-test-subj="categorySelect"
options={categoryOptions}
value={category ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) =>
onChange({ ...fields, category: e.target.value, subcategory: null })
}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.SUBCATEGORY}>
<EuiSelect
fullWidth
data-test-subj="subcategorySelect"
options={subcategoryOptions}
// Needs an empty string instead of undefined to select the blank option when changing categories
value={subcategory ?? ''}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('subcategory', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</div>
) : (
<EuiFlexGroup>
<EuiFlexItem>
<ConnectorCard
connectorType={ConnectorTypes.serviceNowITSM}
title={connector.name}
listItems={listItems}
isLoading={false}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.IMPACT}>
<EuiSelect
fullWidth
data-test-subj="impactSelect"
options={impactOptions}
value={impact ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('impact', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CATEGORY}>
<EuiSelect
fullWidth
data-test-subj="categorySelect"
options={categoryOptions}
value={category ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChange({ ...fields, category: e.target.value, subcategory: null })}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.SUBCATEGORY}>
<EuiSelect
fullWidth
data-test-subj="subcategorySelect"
options={subcategoryOptions}
// Needs an empty string instead of undefined to select the blank option when changing categories
value={subcategory ?? ''}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('subcategory', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</div>
) : (
<ConnectorCard
connectorType={ConnectorTypes.serviceNowITSM}
title={connector.name}
listItems={listItems}
isLoading={false}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
</>
);
};

View file

@ -7,7 +7,7 @@
import React from 'react';
import { mount } from 'enzyme';
import { waitFor, act } from '@testing-library/react';
import { waitFor, act, render, screen } from '@testing-library/react';
import { EuiSelect } from '@elastic/eui';
import { useKibana } from '../../../common/lib/kibana';
@ -68,16 +68,16 @@ describe('ServiceNowSIR Fields', () => {
wrapper.update();
expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual(
'Destination IP: Yes'
'Destination IPs: Yes'
);
expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual(
'Source IP: Yes'
'Source IPs: Yes'
);
expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual(
'Malware URL: Yes'
'Malware URLs: Yes'
);
expect(wrapper.find('[data-test-subj="card-list-item"]').at(3).text()).toEqual(
'Malware Hash: Yes'
'Malware Hashes: Yes'
);
expect(wrapper.find('[data-test-subj="card-list-item"]').at(4).text()).toEqual(
'Priority: 1 - Critical'
@ -161,6 +161,17 @@ describe('ServiceNowSIR Fields', () => {
]);
});
test('it shows the deprecated callout when the connector is legacy', async () => {
const legacyConnector = { ...connector, config: { isLegacy: true } };
render(<Fields fields={fields} onChange={onChange} connector={legacyConnector} />);
expect(screen.getByTestId('legacy-connector-warning-callout')).toBeInTheDocument();
});
test('it does not show the deprecated callout when the connector is not legacy', async () => {
render(<Fields fields={fields} onChange={onChange} connector={connector} />);
expect(screen.queryByTestId('legacy-connector-warning-callout')).not.toBeInTheDocument();
});
describe('onChange calls', () => {
const wrapper = mount(<Fields fields={fields} onChange={onChange} connector={connector} />);

View file

@ -17,6 +17,8 @@ import { Choice, Fields } from './types';
import { choicesToEuiOptions } from './helpers';
import * as i18n from './translations';
import { connectorValidator } from './validator';
import { DeprecatedCallout } from '../deprecated_callout';
const useGetChoicesFields = ['category', 'subcategory', 'priority'];
const defaultFields: Fields = {
@ -40,8 +42,8 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<
} = fields ?? {};
const { http, notifications } = useKibana().services;
const [choices, setChoices] = useState<Fields>(defaultFields);
const showConnectorWarning = useMemo(() => connectorValidator(connector) != null, [connector]);
const onChangeCb = useCallback(
(
@ -166,115 +168,132 @@ const ServiceNowSIRFieldsComponent: React.FunctionComponent<
}
}, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]);
return isEdit ? (
<div data-test-subj={'connector-fields-sn-sir'}>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.ALERT_FIELDS_LABEL}>
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiCheckbox
id="destIpCheckbox"
data-test-subj="destIpCheckbox"
label={i18n.DEST_IP}
checked={destIp ?? false}
compressed
onChange={(e) => onChangeCb('destIp', e.target.checked)}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiCheckbox
id="sourceIpCheckbox"
data-test-subj="sourceIpCheckbox"
label={i18n.SOURCE_IP}
checked={sourceIp ?? false}
compressed
onChange={(e) => onChangeCb('sourceIp', e.target.checked)}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiCheckbox
id="malwareUrlCheckbox"
data-test-subj="malwareUrlCheckbox"
label={i18n.MALWARE_URL}
checked={malwareUrl ?? false}
compressed
onChange={(e) => onChangeCb('malwareUrl', e.target.checked)}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiCheckbox
id="malwareHashCheckbox"
data-test-subj="malwareHashCheckbox"
label={i18n.MALWARE_HASH}
checked={malwareHash ?? false}
compressed
onChange={(e) => onChangeCb('malwareHash', e.target.checked)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.PRIORITY}>
<EuiSelect
fullWidth
data-test-subj="prioritySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={priorityOptions}
value={priority ?? undefined}
onChange={(e) => onChangeCb('priority', e.target.value)}
return (
<>
{showConnectorWarning && (
<EuiFlexGroup>
<EuiFlexItem>
<DeprecatedCallout type={isEdit ? 'danger' : 'warning'} />
</EuiFlexItem>
</EuiFlexGroup>
)}
{isEdit ? (
<div data-test-subj="connector-fields-sn-sir">
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.ALERT_FIELDS_LABEL}>
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiCheckbox
id="destIpCheckbox"
data-test-subj="destIpCheckbox"
label={i18n.DEST_IP}
checked={destIp ?? false}
compressed
onChange={(e) => onChangeCb('destIp', e.target.checked)}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiCheckbox
id="sourceIpCheckbox"
data-test-subj="sourceIpCheckbox"
label={i18n.SOURCE_IP}
checked={sourceIp ?? false}
compressed
onChange={(e) => onChangeCb('sourceIp', e.target.checked)}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiCheckbox
id="malwareUrlCheckbox"
data-test-subj="malwareUrlCheckbox"
label={i18n.MALWARE_URL}
checked={malwareUrl ?? false}
compressed
onChange={(e) => onChangeCb('malwareUrl', e.target.checked)}
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiCheckbox
id="malwareHashCheckbox"
data-test-subj="malwareHashCheckbox"
label={i18n.MALWARE_HASH}
checked={malwareHash ?? false}
compressed
onChange={(e) => onChangeCb('malwareHash', e.target.checked)}
/>
</EuiFlexItem>
</EuiFlexGroup>
</>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.PRIORITY}>
<EuiSelect
fullWidth
data-test-subj="prioritySelect"
hasNoInitialSelection
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={priorityOptions}
value={priority ?? undefined}
onChange={(e) => onChangeCb('priority', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CATEGORY}>
<EuiSelect
fullWidth
data-test-subj="categorySelect"
options={categoryOptions}
value={category ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) =>
onChange({ ...fields, category: e.target.value, subcategory: null })
}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.SUBCATEGORY}>
<EuiSelect
fullWidth
data-test-subj="subcategorySelect"
options={subcategoryOptions}
// Needs an empty string instead of undefined to select the blank option when changing categories
value={subcategory ?? ''}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('subcategory', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</div>
) : (
<EuiFlexGroup>
<EuiFlexItem>
<ConnectorCard
connectorType={ConnectorTypes.serviceNowSIR}
title={connector.name}
listItems={listItems}
isLoading={false}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.CATEGORY}>
<EuiSelect
fullWidth
data-test-subj="categorySelect"
options={categoryOptions}
value={category ?? undefined}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChange({ ...fields, category: e.target.value, subcategory: null })}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth label={i18n.SUBCATEGORY}>
<EuiSelect
fullWidth
data-test-subj="subcategorySelect"
options={subcategoryOptions}
// Needs an empty string instead of undefined to select the blank option when changing categories
value={subcategory ?? ''}
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
hasNoInitialSelection
onChange={(e) => onChangeCb('subcategory', e.target.value)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</div>
) : (
<ConnectorCard
connectorType={ConnectorTypes.serviceNowITSM}
title={connector.name}
listItems={listItems}
isLoading={false}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
</>
);
};

View file

@ -30,11 +30,11 @@ export const CHOICES_API_ERROR = i18n.translate(
);
export const MALWARE_URL = i18n.translate('xpack.cases.connectors.serviceNow.malwareURLTitle', {
defaultMessage: 'Malware URL',
defaultMessage: 'Malware URLs',
});
export const MALWARE_HASH = i18n.translate('xpack.cases.connectors.serviceNow.malwareHashTitle', {
defaultMessage: 'Malware Hash',
defaultMessage: 'Malware Hashes',
});
export const CATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.categoryTitle', {
@ -46,11 +46,11 @@ export const SUBCATEGORY = i18n.translate('xpack.cases.connectors.serviceNow.sub
});
export const SOURCE_IP = i18n.translate('xpack.cases.connectors.serviceNow.sourceIPTitle', {
defaultMessage: 'Source IP',
defaultMessage: 'Source IPs',
});
export const DEST_IP = i18n.translate('xpack.cases.connectors.serviceNow.destinationIPTitle', {
defaultMessage: 'Destination IP',
defaultMessage: 'Destination IPs',
});
export const PRIORITY = i18n.translate(

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { connector } from '../mock';
import { connectorValidator } from './validator';
describe('ServiceNow validator', () => {
describe('connectorValidator', () => {
test('it returns an error message if the connector is legacy', () => {
const invalidConnector = {
...connector,
config: {
...connector.config,
isLegacy: true,
},
};
expect(connectorValidator(invalidConnector)).toEqual({ message: 'Deprecated connector' });
});
test('it does not returns an error message if the connector is not legacy', () => {
const invalidConnector = {
...connector,
config: {
...connector.config,
isLegacy: false,
},
};
expect(connectorValidator(invalidConnector)).toBeFalsy();
});
});
});

View file

@ -0,0 +1,26 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ValidationConfig } from '../../../common/shared_imports';
import { CaseActionConnector } from '../../types';
/**
* The user can not use a legacy connector
*/
export const connectorValidator = (
connector: CaseActionConnector
): ReturnType<ValidationConfig['validator']> => {
const {
config: { isLegacy },
} = connector;
if (isLegacy) {
return {
message: 'Deprecated connector',
};
}
};

View file

@ -22,8 +22,8 @@ import { TestProviders } from '../../common/mock';
import { useCaseConfigure } from '../../containers/configure/use_configure';
import { useCaseConfigureResponse } from '../configure_cases/__mock__';
import { triggersActionsUiMock } from '../../../../triggers_actions_ui/public/mocks';
import { actionTypeRegistryMock } from '../../../../triggers_actions_ui/public/application/action_type_registry.mock';
import { useKibana } from '../../common/lib/kibana';
import { registerConnectorsToMockActionRegistry } from '../../common/mock/register_connectors';
const mockTriggersActionsUiService = triggersActionsUiMock.createStart();
@ -86,14 +86,10 @@ describe('Connector', () => {
return <Form form={form}>{children}</Form>;
};
const { createMockActionTypeModel } = actionTypeRegistryMock;
const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry;
beforeAll(() => {
connectorsMock.forEach((connector) =>
useKibanaMock().services.triggersActionsUi.actionTypeRegistry.register(
createMockActionTypeModel({ id: connector.actionTypeId, iconClass: 'logoSecurity' })
)
);
registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock);
});
beforeEach(() => {

View file

@ -5,6 +5,4 @@
* 2.0.
*/
import { ActionConnector } from '../../common';
export type CaseActionConnector = ActionConnector;
export { CaseActionConnector } from '../../common';

View file

@ -10,7 +10,13 @@ import { ConnectorTypes } from '../../common';
import { FieldConfig, ValidationConfig } from '../common/shared_imports';
import { StartPlugins } from '../types';
import { connectorValidator as swimlaneConnectorValidator } from './connectors/swimlane/validator';
import { connectorValidator as servicenowConnectorValidator } from './connectors/servicenow/validator';
import { CaseActionConnector } from './types';
import {
ENABLE_NEW_SN_ITSM_CONNECTOR,
ENABLE_NEW_SN_SIR_CONNECTOR,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../actions/server/constants/connectors';
export const getConnectorById = (
id: string,
@ -22,6 +28,8 @@ const validators: Record<
(connector: CaseActionConnector) => ReturnType<ValidationConfig['validator']>
> = {
[ConnectorTypes.swimlane]: swimlaneConnectorValidator,
[ConnectorTypes.serviceNowITSM]: servicenowConnectorValidator,
[ConnectorTypes.serviceNowSIR]: servicenowConnectorValidator,
};
export const getConnectorsFormValidators = ({
@ -68,3 +76,20 @@ export const getConnectorIcon = (
return emptyResponse;
};
// TODO: Remove when the applications are certified
export const isLegacyConnector = (connector?: CaseActionConnector) => {
if (connector == null) {
return true;
}
if (!ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow') {
return true;
}
if (!ENABLE_NEW_SN_SIR_CONNECTOR && connector.actionTypeId === '.servicenow-sir') {
return true;
}
return connector.config.isLegacy;
};

View file

@ -71,6 +71,16 @@ export const connectorsMock: ActionConnector[] = [
},
isPreconfigured: false,
},
{
id: 'servicenow-legacy',
actionTypeId: '.servicenow',
name: 'My Connector',
config: {
apiUrl: 'https://instance1.service-now.com',
isLegacy: true,
},
isPreconfigured: false,
},
];
export const actionTypesMock: ActionTypeConnector[] = [

View file

@ -10,6 +10,7 @@ import { format } from './itsm_format';
describe('ITSM formatter', () => {
const theCase = {
id: 'case-id',
connector: {
fields: { severity: '2', urgency: '2', impact: '2', category: 'software', subcategory: 'os' },
},
@ -17,7 +18,11 @@ describe('ITSM formatter', () => {
it('it formats correctly', async () => {
const res = await format(theCase, []);
expect(res).toEqual(theCase.connector.fields);
expect(res).toEqual({
...theCase.connector.fields,
correlation_display: 'Elastic Case',
correlation_id: 'case-id',
});
});
it('it formats correctly when fields do not exist ', async () => {
@ -29,6 +34,8 @@ describe('ITSM formatter', () => {
impact: null,
category: null,
subcategory: null,
correlation_display: 'Elastic Case',
correlation_id: null,
});
});
});

View file

@ -16,5 +16,13 @@ export const format: ServiceNowITSMFormat = (theCase, alerts) => {
category = null,
subcategory = null,
} = (theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {};
return { severity, urgency, impact, category, subcategory };
return {
severity,
urgency,
impact,
category,
subcategory,
correlation_id: theCase.id ?? null,
correlation_display: 'Elastic Case',
};
};

View file

@ -10,6 +10,7 @@ import { format } from './sir_format';
describe('ITSM formatter', () => {
const theCase = {
id: 'case-id',
connector: {
fields: {
destIp: true,
@ -26,13 +27,15 @@ describe('ITSM formatter', () => {
it('it formats correctly without alerts', async () => {
const res = await format(theCase, []);
expect(res).toEqual({
dest_ip: null,
source_ip: null,
dest_ip: [],
source_ip: [],
category: 'Denial of Service',
subcategory: 'Inbound DDos',
malware_hash: null,
malware_url: null,
malware_hash: [],
malware_url: [],
priority: '2 - High',
correlation_display: 'Elastic Case',
correlation_id: 'case-id',
});
});
@ -40,13 +43,15 @@ describe('ITSM formatter', () => {
const invalidFields = { connector: { fields: null } } as CaseResponse;
const res = await format(invalidFields, []);
expect(res).toEqual({
dest_ip: null,
source_ip: null,
dest_ip: [],
source_ip: [],
category: null,
subcategory: null,
malware_hash: null,
malware_url: null,
malware_hash: [],
malware_url: [],
priority: null,
correlation_display: 'Elastic Case',
correlation_id: null,
});
});
@ -75,14 +80,18 @@ describe('ITSM formatter', () => {
];
const res = await format(theCase, alerts);
expect(res).toEqual({
dest_ip: '192.168.1.1,192.168.1.4',
source_ip: '192.168.1.2,192.168.1.3',
dest_ip: ['192.168.1.1', '192.168.1.4'],
source_ip: ['192.168.1.2', '192.168.1.3'],
category: 'Denial of Service',
subcategory: 'Inbound DDos',
malware_hash:
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08,60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752',
malware_url: 'https://attack.com,https://attack.com/api',
malware_hash: [
'9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
'60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752',
],
malware_url: ['https://attack.com', 'https://attack.com/api'],
priority: '2 - High',
correlation_display: 'Elastic Case',
correlation_id: 'case-id',
});
});
@ -111,13 +120,15 @@ describe('ITSM formatter', () => {
];
const res = await format(theCase, alerts);
expect(res).toEqual({
dest_ip: '192.168.1.1',
source_ip: '192.168.1.2,192.168.1.3',
dest_ip: ['192.168.1.1'],
source_ip: ['192.168.1.2', '192.168.1.3'],
category: 'Denial of Service',
subcategory: 'Inbound DDos',
malware_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08',
malware_url: 'https://attack.com,https://attack.com/api',
malware_hash: ['9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08'],
malware_url: ['https://attack.com', 'https://attack.com/api'],
priority: '2 - High',
correlation_display: 'Elastic Case',
correlation_id: 'case-id',
});
});
@ -152,13 +163,15 @@ describe('ITSM formatter', () => {
const res = await format(newCase, alerts);
expect(res).toEqual({
dest_ip: null,
source_ip: '192.168.1.2,192.168.1.3',
dest_ip: [],
source_ip: ['192.168.1.2', '192.168.1.3'],
category: 'Denial of Service',
subcategory: 'Inbound DDos',
malware_hash: null,
malware_url: 'https://attack.com,https://attack.com/api',
malware_hash: [],
malware_url: ['https://attack.com', 'https://attack.com/api'],
priority: '2 - High',
correlation_display: 'Elastic Case',
correlation_id: 'case-id',
});
});
});

View file

@ -32,11 +32,11 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => {
malware_url: new Set(),
};
let sirFields: Record<SirFieldKey, string | null> = {
dest_ip: null,
source_ip: null,
malware_hash: null,
malware_url: null,
let sirFields: Record<SirFieldKey, string[]> = {
dest_ip: [],
source_ip: [],
malware_hash: [],
malware_url: [],
};
const fieldsToAdd = (Object.keys(alertFieldMapping) as SirFieldKey[]).filter(
@ -44,18 +44,17 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => {
);
if (fieldsToAdd.length > 0) {
sirFields = alerts.reduce<Record<SirFieldKey, string | null>>((acc, alert) => {
sirFields = alerts.reduce<Record<SirFieldKey, string[]>>((acc, alert) => {
fieldsToAdd.forEach((alertField) => {
const field = get(alertFieldMapping[alertField].alertPath, alert);
if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) {
manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field);
acc = {
...acc,
[alertFieldMapping[alertField].sirFieldKey]: `${
acc[alertFieldMapping[alertField].sirFieldKey] != null
? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}`
: field
}`,
[alertFieldMapping[alertField].sirFieldKey]: [
...acc[alertFieldMapping[alertField].sirFieldKey],
field,
],
};
}
});
@ -68,5 +67,7 @@ export const format: ServiceNowSIRFormat = (theCase, alerts) => {
category,
subcategory,
priority,
correlation_id: theCase.id ?? null,
correlation_display: 'Elastic Case',
};
};

View file

@ -8,13 +8,18 @@
import { ServiceNowITSMFieldsType } from '../../../common';
import { ICasesConnector } from '../types';
export interface ServiceNowSIRFieldsType {
dest_ip: string | null;
source_ip: string | null;
interface CorrelationValues {
correlation_id: string | null;
correlation_display: string | null;
}
export interface ServiceNowSIRFieldsType extends CorrelationValues {
dest_ip: string[] | null;
source_ip: string[] | null;
category: string | null;
subcategory: string | null;
malware_hash: string | null;
malware_url: string | null;
malware_hash: string[] | null;
malware_url: string[] | null;
priority: string | null;
}
@ -26,7 +31,9 @@ export type AlertFieldMappingAndValues = Record<
// ServiceNow ITSM
export type ServiceNowITSMCasesConnector = ICasesConnector<ServiceNowITSMFieldsType>;
export type ServiceNowITSMFormat = ICasesConnector<ServiceNowITSMFieldsType>['format'];
export type ServiceNowITSMFormat = ICasesConnector<
ServiceNowITSMFieldsType & CorrelationValues
>['format'];
export type ServiceNowITSMGetMapping = ICasesConnector<ServiceNowITSMFieldsType>['getMapping'];
// ServiceNow SIR

View file

@ -301,6 +301,7 @@ export const NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS = [
'.swimlane',
'.webhook',
'.servicenow',
'.servicenow-sir',
'.jira',
'.resilient',
'.teams',

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { getServiceNowConnector } from '../../objects/case';
import { getServiceNowConnector, getServiceNowITSMHealthResponse } from '../../objects/case';
import { SERVICE_NOW_MAPPING, TOASTER } from '../../screens/configure_cases';
@ -43,8 +43,16 @@ describe('Cases connectors', () => {
id: '123',
owner: 'securitySolution',
};
const snConnector = getServiceNowConnector();
beforeEach(() => {
cleanKibana();
cy.intercept('GET', `${snConnector.URL}/api/x_elas2_inc_int/elastic_api/health*`, {
statusCode: 200,
body: getServiceNowITSMHealthResponse(),
});
cy.intercept('POST', '/api/actions/connector').as('createConnector');
cy.intercept('POST', '/api/cases/configure', (req) => {
const connector = req.body.connector;
@ -52,6 +60,7 @@ describe('Cases connectors', () => {
res.send(200, { ...configureResult, connector });
});
}).as('saveConnector');
cy.intercept('GET', '/api/cases/configure', (req) => {
req.reply((res) => {
const resBody =
@ -77,7 +86,7 @@ describe('Cases connectors', () => {
loginAndWaitForPageWithoutDateRange(CASES_URL);
goToEditExternalConnection();
openAddNewConnectorOption();
addServiceNowConnector(getServiceNowConnector());
addServiceNowConnector(snConnector);
cy.wait('@createConnector').then(({ response }) => {
cy.wrap(response!.statusCode).should('eql', 200);

View file

@ -44,6 +44,14 @@ export interface IbmResilientConnectorOptions {
incidentTypes: string[];
}
interface ServiceNowHealthResponse {
result: {
name: string;
scope: string;
version: string;
};
}
export const getCase1 = (): TestCase => ({
name: 'This is the title of the case',
tags: ['Tag1', 'Tag2'],
@ -60,6 +68,14 @@ export const getServiceNowConnector = (): Connector => ({
password: 'password',
});
export const getServiceNowITSMHealthResponse = (): ServiceNowHealthResponse => ({
result: {
name: 'Elastic',
scope: 'x_elas2_inc_int',
version: '1.0.0',
},
});
export const getJiraConnectorOptions = (): JiraConnectorOptions => ({
issueType: '10006',
priority: 'High',

View file

@ -25052,7 +25052,6 @@
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage": "選択肢を取得できません",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel": "緊急",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "ユーザー名",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "Personal Developer Instance の構成",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle": "ServiceNow ITSM",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText": "ServiceNow ITSMでインシデントを作成します。",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle": "ServiceNow SecOps",

View file

@ -25480,7 +25480,6 @@
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unableToGetChoicesMessage": "无法获取选项",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.urgencySelectFieldLabel": "紧急性",
"xpack.triggersActionsUI.components.builtinActionTypes.servicenow.usernameTextFieldLabel": "用户名",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel": "配置个人开发者实例",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.actionTypeTitle": "ServiceNow ITSM",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITSM.selectMessageText": "在 ServiceNow ITSM 中创建事件。",
"xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle": "ServiceNow SecOps",

View file

@ -35,6 +35,8 @@ describe('EmailActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy();
@ -66,6 +68,8 @@ describe('EmailActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy();
@ -99,6 +103,8 @@ describe('EmailActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="emailFromInput"]').first().prop('value')).toBe(
@ -132,6 +138,8 @@ describe('EmailActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="emailServiceSelectInput"]').length > 0).toBeTruthy();
@ -165,6 +173,8 @@ describe('EmailActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(true);
@ -199,6 +209,8 @@ describe('EmailActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="emailHostInput"]').first().prop('disabled')).toBe(false);
@ -223,6 +235,8 @@ describe('EmailActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
@ -245,6 +259,8 @@ describe('EmailActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
@ -268,6 +284,8 @@ describe('EmailActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);

View file

@ -71,6 +71,8 @@ describe('IndexActionConnectorFields renders', () => {
editActionSecrets: () => {},
errors: { index: [] },
readOnly: false,
setCallbacks: () => {},
isEdit: false,
};
const wrapper = mountWithIntl(<IndexActionConnectorFields {...props} />);

View file

@ -34,6 +34,8 @@ describe('JiraActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
@ -74,6 +76,8 @@ describe('JiraActionConnectorFields renders', () => {
editActionSecrets={() => {}}
readOnly={false}
consumer={'case'}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy();
@ -104,6 +108,8 @@ describe('JiraActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
@ -125,6 +131,8 @@ describe('JiraActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
@ -152,6 +160,8 @@ describe('JiraActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);

View file

@ -33,6 +33,8 @@ describe('PagerDutyActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
@ -61,6 +63,8 @@ describe('PagerDutyActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
@ -86,6 +90,8 @@ describe('PagerDutyActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
@ -112,6 +118,8 @@ describe('PagerDutyActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);

View file

@ -34,6 +34,8 @@ describe('ResilientActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
@ -74,6 +76,8 @@ describe('ResilientActionConnectorFields renders', () => {
editActionSecrets={() => {}}
readOnly={false}
consumer={'case'}
setCallbacks={() => {}}
isEdit={false}
/>
);
@ -105,6 +109,8 @@ describe('ResilientActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
@ -126,6 +132,8 @@ describe('ResilientActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
@ -153,6 +161,8 @@ describe('ResilientActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);

View file

@ -6,7 +6,7 @@
*/
import { httpServiceMock } from '../../../../../../../../src/core/public/mocks';
import { getChoices } from './api';
import { getChoices, getAppInfo } from './api';
const choicesResponse = {
status: 'ok',
@ -44,10 +44,27 @@ const choicesResponse = {
],
};
const applicationInfoData = {
result: { name: 'Elastic', scope: 'x_elas2_inc_int', version: '1.0.0' },
};
const applicationInfoResponse = {
ok: true,
status: 200,
json: async () => applicationInfoData,
};
describe('ServiceNow API', () => {
const http = httpServiceMock.createStartContract();
let fetchMock: jest.SpyInstance<Promise<unknown>>;
beforeEach(() => jest.resetAllMocks());
beforeAll(() => {
fetchMock = jest.spyOn(window, 'fetch');
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('getChoices', () => {
test('should call get choices API', async () => {
@ -67,4 +84,96 @@ describe('ServiceNow API', () => {
});
});
});
describe('getAppInfo', () => {
test('should call getAppInfo API for ITSM', async () => {
const abortCtrl = new AbortController();
fetchMock.mockResolvedValueOnce(applicationInfoResponse);
const res = await getAppInfo({
signal: abortCtrl.signal,
apiUrl: 'https://example.com',
username: 'test',
password: 'test',
actionTypeId: '.servicenow',
});
expect(res).toEqual(applicationInfoData.result);
expect(fetchMock).toHaveBeenCalledWith(
'https://example.com/api/x_elas2_inc_int/elastic_api/health',
{
signal: abortCtrl.signal,
method: 'GET',
headers: { Authorization: 'Basic dGVzdDp0ZXN0' },
}
);
});
test('should call getAppInfo API correctly for SIR', async () => {
const abortCtrl = new AbortController();
fetchMock.mockResolvedValueOnce(applicationInfoResponse);
const res = await getAppInfo({
signal: abortCtrl.signal,
apiUrl: 'https://example.com',
username: 'test',
password: 'test',
actionTypeId: '.servicenow-sir',
});
expect(res).toEqual(applicationInfoData.result);
expect(fetchMock).toHaveBeenCalledWith(
'https://example.com/api/x_elas2_sir_int/elastic_api/health',
{
signal: abortCtrl.signal,
method: 'GET',
headers: { Authorization: 'Basic dGVzdDp0ZXN0' },
}
);
});
it('returns an error when the response fails', async () => {
expect.assertions(1);
const abortCtrl = new AbortController();
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => applicationInfoResponse.json,
});
await expect(() =>
getAppInfo({
signal: abortCtrl.signal,
apiUrl: 'https://example.com',
username: 'test',
password: 'test',
actionTypeId: '.servicenow',
})
).rejects.toThrow('Received status:');
});
it('returns an error when parsing the json fails', async () => {
expect.assertions(1);
const abortCtrl = new AbortController();
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => {
throw new Error('bad');
},
});
await expect(() =>
getAppInfo({
signal: abortCtrl.signal,
apiUrl: 'https://example.com',
username: 'test',
password: 'test',
actionTypeId: '.servicenow',
})
).rejects.toThrow('bad');
});
});
});

View file

@ -6,7 +6,11 @@
*/
import { HttpSetup } from 'kibana/public';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { snExternalServiceConfig } from '../../../../../../actions/server/builtin_action_types/servicenow/config';
import { BASE_ACTION_API_PATH } from '../../../constants';
import { API_INFO_ERROR } from './translations';
import { AppInfo, RESTApiError } from './types';
export async function getChoices({
http,
@ -29,3 +33,43 @@ export async function getChoices({
}
);
}
/**
* The app info url should be the same as at:
* x-pack/plugins/actions/server/builtin_action_types/servicenow/service.ts
*/
const getAppInfoUrl = (url: string, scope: string) => `${url}/api/${scope}/elastic_api/health`;
export async function getAppInfo({
signal,
apiUrl,
username,
password,
actionTypeId,
}: {
signal: AbortSignal;
apiUrl: string;
username: string;
password: string;
actionTypeId: string;
}): Promise<AppInfo | RESTApiError> {
const urlWithoutTrailingSlash = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
const config = snExternalServiceConfig[actionTypeId];
const response = await fetch(getAppInfoUrl(urlWithoutTrailingSlash, config.appScope ?? ''), {
method: 'GET',
signal,
headers: {
Authorization: 'Basic ' + btoa(username + ':' + password),
},
});
if (!response.ok) {
throw new Error(API_INFO_ERROR(response.status));
}
const data = await response.json();
return {
...data.result,
};
}

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { ApplicationRequiredCallout } from './application_required_callout';
describe('ApplicationRequiredCallout', () => {
test('it renders the callout', () => {
render(<ApplicationRequiredCallout />);
expect(screen.getByText('Elastic ServiceNow App not installed')).toBeInTheDocument();
expect(
screen.getByText('Please go to the ServiceNow app store and install the application')
).toBeInTheDocument();
});
test('it renders the ServiceNow store button', () => {
render(<ApplicationRequiredCallout />);
expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument();
});
test('it renders an error message if provided', () => {
render(<ApplicationRequiredCallout message="Denied" />);
expect(screen.getByText('Error message: Denied')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,60 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import { EuiSpacer, EuiCallOut } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { SNStoreButton } from './sn_store_button';
const content = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout.content',
{
defaultMessage: 'Please go to the ServiceNow app store and install the application',
}
);
const ERROR_MESSAGE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout.errorMessage',
{
defaultMessage: 'Error message',
}
);
interface Props {
message?: string | null;
}
const ApplicationRequiredCalloutComponent: React.FC<Props> = ({ message }) => {
return (
<>
<EuiSpacer size="s" />
<EuiCallOut
size="m"
iconType="alert"
data-test-subj="snDeprecatedCallout"
color="danger"
title={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.applicationRequiredCallout',
{
defaultMessage: 'Elastic ServiceNow App not installed',
}
)}
>
<p>{content}</p>
{message && (
<p>
{ERROR_MESSAGE}: {message}
</p>
)}
<SNStoreButton color="danger" />
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
};
export const ApplicationRequiredCallout = memo(ApplicationRequiredCalloutComponent);

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const UPDATE_INCIDENT_VARIABLE = '{{rule.id}}';
export const NOT_UPDATE_INCIDENT_VARIABLE = '{{rule.id}}:{{alert.id}}';

View file

@ -0,0 +1,191 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo, useCallback } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiLink,
EuiFieldText,
EuiSpacer,
EuiTitle,
EuiFieldPassword,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { ActionConnectorFieldsProps } from '../../../../../public/types';
import { useKibana } from '../../../../common/lib/kibana';
import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label';
import * as i18n from './translations';
import { ServiceNowActionConnector } from './types';
import { isFieldInvalid } from './helpers';
interface Props {
action: ActionConnectorFieldsProps<ServiceNowActionConnector>['action'];
errors: ActionConnectorFieldsProps<ServiceNowActionConnector>['errors'];
readOnly: boolean;
isLoading: boolean;
editActionSecrets: ActionConnectorFieldsProps<ServiceNowActionConnector>['editActionSecrets'];
editActionConfig: ActionConnectorFieldsProps<ServiceNowActionConnector>['editActionConfig'];
}
const CredentialsComponent: React.FC<Props> = ({
action,
errors,
readOnly,
isLoading,
editActionSecrets,
editActionConfig,
}) => {
const { docLinks } = useKibana().services;
const { apiUrl } = action.config;
const { username, password } = action.secrets;
const isApiUrlInvalid = isFieldInvalid(apiUrl, errors.apiUrl);
const isUsernameInvalid = isFieldInvalid(username, errors.username);
const isPasswordInvalid = isFieldInvalid(password, errors.password);
const handleOnChangeActionConfig = useCallback(
(key: string, value: string) => editActionConfig(key, value),
[editActionConfig]
);
const handleOnChangeSecretConfig = useCallback(
(key: string, value: string) => editActionSecrets(key, value),
[editActionSecrets]
);
return (
<>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTitle size="xxs">
<h4>{i18n.SN_INSTANCE_LABEL}</h4>
</EuiTitle>
<p>
<FormattedMessage
id="xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel"
defaultMessage="Please provide the full URL to the desired ServiceNow instance. If you do not have one, you can {instance}"
values={{
instance: (
<EuiLink href={docLinks.links.alerting.serviceNowAction} target="_blank">
{i18n.SETUP_DEV_INSTANCE}
</EuiLink>
),
}}
/>
</p>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
id="apiUrl"
fullWidth
error={errors.apiUrl}
isInvalid={isApiUrlInvalid}
label={i18n.API_URL_LABEL}
helpText={i18n.API_URL_HELPTEXT}
>
<EuiFieldText
fullWidth
isInvalid={isApiUrlInvalid}
name="apiUrl"
readOnly={readOnly}
value={apiUrl || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="apiUrlFromInput"
onChange={(evt) => handleOnChangeActionConfig('apiUrl', evt.target.value)}
onBlur={() => {
if (!apiUrl) {
editActionConfig('apiUrl', '');
}
}}
disabled={isLoading}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xxs">
<h4>{i18n.AUTHENTICATION_LABEL}</h4>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth>
{getEncryptedFieldNotifyLabel(
!action.id,
2,
action.isMissingSecrets ?? false,
i18n.REENTER_VALUES_LABEL
)}
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="connector-servicenow-username"
fullWidth
error={errors.username}
isInvalid={isUsernameInvalid}
label={i18n.USERNAME_LABEL}
>
<EuiFieldText
fullWidth
isInvalid={isUsernameInvalid}
readOnly={readOnly}
name="connector-servicenow-username"
value={username || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="connector-servicenow-username-form-input"
onChange={(evt) => handleOnChangeSecretConfig('username', evt.target.value)}
onBlur={() => {
if (!username) {
editActionSecrets('username', '');
}
}}
disabled={isLoading}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="connector-servicenow-password"
fullWidth
error={errors.password}
isInvalid={isPasswordInvalid}
label={i18n.PASSWORD_LABEL}
>
<EuiFieldPassword
fullWidth
readOnly={readOnly}
isInvalid={isPasswordInvalid}
name="connector-servicenow-password"
value={password || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="connector-servicenow-password-form-input"
onChange={(evt) => handleOnChangeSecretConfig('password', evt.target.value)}
onBlur={() => {
if (!password) {
editActionSecrets('password', '');
}
}}
disabled={isLoading}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};
export const Credentials = memo(CredentialsComponent);

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { I18nProvider } from '@kbn/i18n/react';
import { DeprecatedCallout } from './deprecated_callout';
describe('DeprecatedCallout', () => {
const onMigrate = jest.fn();
test('it renders correctly', () => {
render(<DeprecatedCallout onMigrate={onMigrate} />, {
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
});
expect(screen.getByText('Deprecated connector type')).toBeInTheDocument();
});
test('it calls onMigrate when pressing the button', () => {
render(<DeprecatedCallout onMigrate={onMigrate} />, {
wrapper: ({ children }) => <I18nProvider>{children}</I18nProvider>,
});
const button = screen.getByRole('button');
fireEvent.click(button);
expect(onMigrate).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import { EuiSpacer, EuiCallOut, EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
interface Props {
onMigrate: () => void;
}
const DeprecatedCalloutComponent: React.FC<Props> = ({ onMigrate }) => {
return (
<>
<EuiSpacer size="s" />
<EuiCallOut
size="m"
iconType="alert"
data-test-subj="snDeprecatedCallout"
color="warning"
title={i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutTitle',
{
defaultMessage: 'Deprecated connector type',
}
)}
>
<FormattedMessage
defaultMessage="This connector type is deprecated. Create a new connector or {migrate}"
id="xpack.triggersActionsUI.components.builtinActionTypes.servicenow.appInstallationInfo"
values={{
migrate: (
<EuiButtonEmpty onClick={onMigrate} flush="left">
{i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.deprecatedCalloutMigrate',
{
defaultMessage: 'update this connector.',
}
)}
</EuiButtonEmpty>
),
}}
/>
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
};
export const DeprecatedCallout = memo(DeprecatedCalloutComponent);

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isRESTApiError, isFieldInvalid } from './helpers';
describe('helpers', () => {
describe('isRESTApiError', () => {
const resError = { error: { message: 'error', detail: 'access denied' }, status: '401' };
test('should return true if the error is RESTApiError', async () => {
expect(isRESTApiError(resError)).toBeTruthy();
});
test('should return true if there is failure status', async () => {
// @ts-expect-error
expect(isRESTApiError({ status: 'failure' })).toBeTruthy();
});
test('should return false if there is no error', async () => {
// @ts-expect-error
expect(isRESTApiError({ whatever: 'test' })).toBeFalsy();
});
});
describe('isFieldInvalid', () => {
test('should return true if the field is invalid', async () => {
expect(isFieldInvalid('description', ['required'])).toBeTruthy();
});
test('should return if false the field is not defined', async () => {
expect(isFieldInvalid(undefined, ['required'])).toBeFalsy();
});
test('should return if false the error is not defined', async () => {
// @ts-expect-error
expect(isFieldInvalid('description', undefined)).toBeFalsy();
});
test('should return if false the error is empty', async () => {
expect(isFieldInvalid('description', [])).toBeFalsy();
});
});
});

View file

@ -6,7 +6,38 @@
*/
import { EuiSelectOption } from '@elastic/eui';
import { Choice } from './types';
import {
ENABLE_NEW_SN_ITSM_CONNECTOR,
ENABLE_NEW_SN_SIR_CONNECTOR,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../../../actions/server/constants/connectors';
import { IErrorObject } from '../../../../../public/types';
import { AppInfo, Choice, RESTApiError, ServiceNowActionConnector } from './types';
export const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] =>
choices.map((choice) => ({ value: choice.value, text: choice.label }));
export const isRESTApiError = (res: AppInfo | RESTApiError): res is RESTApiError =>
(res as RESTApiError).error != null || (res as RESTApiError).status === 'failure';
export const isFieldInvalid = (
field: string | undefined,
error: string | IErrorObject | string[]
): boolean => error !== undefined && error.length > 0 && field !== undefined;
// TODO: Remove when the applications are certified
export const isLegacyConnector = (connector: ServiceNowActionConnector) => {
if (connector == null) {
return true;
}
if (!ENABLE_NEW_SN_ITSM_CONNECTOR && connector.actionTypeId === '.servicenow') {
return true;
}
if (!ENABLE_NEW_SN_SIR_CONNECTOR && connector.actionTypeId === '.servicenow-sir') {
return true;
}
return connector.config.isLegacy;
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { InstallationCallout } from './installation_callout';
describe('DeprecatedCallout', () => {
test('it renders correctly', () => {
render(<InstallationCallout />);
expect(
screen.getByText(
'To use this connector, you must first install the Elastic App from the ServiceNow App Store'
)
).toBeInTheDocument();
});
test('it renders the button', () => {
render(<InstallationCallout />);
expect(screen.getByRole('link')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import { EuiSpacer, EuiCallOut } from '@elastic/eui';
import * as i18n from './translations';
import { SNStoreButton } from './sn_store_button';
const InstallationCalloutComponent: React.FC = () => {
return (
<>
<EuiSpacer size="s" />
<EuiCallOut
size="m"
iconType="alert"
color="warning"
data-test-subj="snInstallationCallout"
title={i18n.INSTALLATION_CALLOUT_TITLE}
>
<SNStoreButton color="warning" />
</EuiCallOut>
<EuiSpacer size="m" />
</>
);
};
export const InstallationCallout = memo(InstallationCalloutComponent);

View file

@ -43,6 +43,7 @@ describe('servicenow connector validation', () => {
isPreconfigured: false,
config: {
apiUrl: 'https://dev94428.service-now.com/',
isLegacy: false,
},
} as ServiceNowActionConnector;
@ -50,6 +51,7 @@ describe('servicenow connector validation', () => {
config: {
errors: {
apiUrl: [],
isLegacy: [],
},
},
secrets: {
@ -77,6 +79,7 @@ describe('servicenow connector validation', () => {
config: {
errors: {
apiUrl: ['URL is required.'],
isLegacy: [],
},
},
secrets: {

View file

@ -27,6 +27,7 @@ const validateConnector = async (
const translations = await import('./translations');
const configErrors = {
apiUrl: new Array<string>(),
isLegacy: new Array<string>(),
};
const secretsErrors = {
username: new Array<string>(),

View file

@ -33,6 +33,8 @@ describe('ServiceNowActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(
@ -57,8 +59,7 @@ describe('ServiceNowActionConnectorFields renders', () => {
name: 'servicenow',
config: {
apiUrl: 'https://test/',
incidentConfiguration: { mapping: [] },
isCaseOwned: true,
isLegacy: false,
},
} as ServiceNowActionConnector;
const wrapper = mountWithIntl(
@ -69,6 +70,8 @@ describe('ServiceNowActionConnectorFields renders', () => {
editActionSecrets={() => {}}
readOnly={false}
consumer={'case'}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="apiUrlFromInput"]').length > 0).toBeTruthy();
@ -91,6 +94,8 @@ describe('ServiceNowActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
@ -112,6 +117,8 @@ describe('ServiceNowActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
@ -138,6 +145,8 @@ describe('ServiceNowActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);

View file

@ -5,162 +5,142 @@
* 2.0.
*/
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import {
EuiFieldText,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiFieldPassword,
EuiSpacer,
EuiLink,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { ActionConnectorFieldsProps } from '../../../../types';
import * as i18n from './translations';
import { ServiceNowActionConnector } from './types';
import { useKibana } from '../../../../common/lib/kibana';
import { getEncryptedFieldNotifyLabel } from '../../get_encrypted_field_notify_label';
import { DeprecatedCallout } from './deprecated_callout';
import { useGetAppInfo } from './use_get_app_info';
import { ApplicationRequiredCallout } from './application_required_callout';
import { isRESTApiError, isLegacyConnector } from './helpers';
import { InstallationCallout } from './installation_callout';
import { UpdateConnectorModal } from './update_connector_modal';
import { updateActionConnector } from '../../../lib/action_connector_api';
import { Credentials } from './credentials';
const ServiceNowConnectorFields: React.FC<ActionConnectorFieldsProps<ServiceNowActionConnector>> =
({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly }) => {
const { docLinks } = useKibana().services;
({
action,
editActionSecrets,
editActionConfig,
errors,
consumer,
readOnly,
setCallbacks,
isEdit,
}) => {
const {
http,
notifications: { toasts },
} = useKibana().services;
const { apiUrl } = action.config;
const isApiUrlInvalid: boolean =
errors.apiUrl !== undefined && errors.apiUrl.length > 0 && apiUrl !== undefined;
const { username, password } = action.secrets;
const isOldConnector = isLegacyConnector(action);
const isUsernameInvalid: boolean =
errors.username !== undefined && errors.username.length > 0 && username !== undefined;
const isPasswordInvalid: boolean =
errors.password !== undefined && errors.password.length > 0 && password !== undefined;
const [showModal, setShowModal] = useState(false);
const handleOnChangeActionConfig = useCallback(
(key: string, value: string) => editActionConfig(key, value),
[editActionConfig]
const { fetchAppInfo, isLoading } = useGetAppInfo({
actionTypeId: action.actionTypeId,
});
const [applicationRequired, setApplicationRequired] = useState<boolean>(false);
const [applicationInfoErrorMsg, setApplicationInfoErrorMsg] = useState<string | null>(null);
const getApplicationInfo = useCallback(async () => {
setApplicationRequired(false);
setApplicationInfoErrorMsg(null);
try {
const res = await fetchAppInfo(action);
if (isRESTApiError(res)) {
throw new Error(res.error?.message ?? i18n.UNKNOWN);
}
return res;
} catch (e) {
setApplicationRequired(true);
setApplicationInfoErrorMsg(e.message);
// We need to throw here so the connector will be not be saved.
throw e;
}
}, [action, fetchAppInfo]);
const beforeActionConnectorSave = useCallback(async () => {
if (!isOldConnector) {
await getApplicationInfo();
}
}, [getApplicationInfo, isOldConnector]);
useEffect(
() => setCallbacks({ beforeActionConnectorSave }),
[beforeActionConnectorSave, setCallbacks]
);
const handleOnChangeSecretConfig = useCallback(
(key: string, value: string) => editActionSecrets(key, value),
[editActionSecrets]
);
const onMigrateClick = useCallback(() => setShowModal(true), []);
const onModalCancel = useCallback(() => setShowModal(false), []);
const onModalConfirm = useCallback(async () => {
await getApplicationInfo();
await updateActionConnector({
http,
connector: {
name: action.name,
config: { apiUrl, isLegacy: false },
secrets: { username, password },
},
id: action.id,
});
editActionConfig('isLegacy', false);
setShowModal(false);
toasts.addSuccess({
title: i18n.MIGRATION_SUCCESS_TOAST_TITLE(action.name),
text: i18n.MIGRATION_SUCCESS_TOAST_TEXT,
});
}, [
getApplicationInfo,
http,
action.name,
action.id,
apiUrl,
username,
password,
editActionConfig,
toasts,
]);
return (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="apiUrl"
fullWidth
error={errors.apiUrl}
isInvalid={isApiUrlInvalid}
label={i18n.API_URL_LABEL}
helpText={
<EuiLink href={docLinks.links.alerting.serviceNowAction} target="_blank">
<FormattedMessage
id="xpack.triggersActionsUI.components.builtinActionTypes.serviceNowAction.apiUrlHelpLabel"
defaultMessage="Configure a Personal Developer Instance"
/>
</EuiLink>
}
>
<EuiFieldText
fullWidth
isInvalid={isApiUrlInvalid}
name="apiUrl"
readOnly={readOnly}
value={apiUrl || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="apiUrlFromInput"
onChange={(evt) => handleOnChangeActionConfig('apiUrl', evt.target.value)}
onBlur={() => {
if (!apiUrl) {
editActionConfig('apiUrl', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xxs">
<h4>{i18n.AUTHENTICATION_LABEL}</h4>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow fullWidth>
{getEncryptedFieldNotifyLabel(
!action.id,
2,
action.isMissingSecrets ?? false,
i18n.REENTER_VALUES_LABEL
)}
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="connector-servicenow-username"
fullWidth
error={errors.username}
isInvalid={isUsernameInvalid}
label={i18n.USERNAME_LABEL}
>
<EuiFieldText
fullWidth
isInvalid={isUsernameInvalid}
readOnly={readOnly}
name="connector-servicenow-username"
value={username || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="connector-servicenow-username-form-input"
onChange={(evt) => handleOnChangeSecretConfig('username', evt.target.value)}
onBlur={() => {
if (!username) {
editActionSecrets('username', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
id="connector-servicenow-password"
fullWidth
error={errors.password}
isInvalid={isPasswordInvalid}
label={i18n.PASSWORD_LABEL}
>
<EuiFieldPassword
fullWidth
readOnly={readOnly}
isInvalid={isPasswordInvalid}
name="connector-servicenow-password"
value={password || ''} // Needed to prevent uncontrolled input error when value is undefined
data-test-subj="connector-servicenow-password-form-input"
onChange={(evt) => handleOnChangeSecretConfig('password', evt.target.value)}
onBlur={() => {
if (!password) {
editActionSecrets('password', '');
}
}}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
{showModal && (
<UpdateConnectorModal
action={action}
applicationInfoErrorMsg={applicationInfoErrorMsg}
errors={errors}
readOnly={readOnly}
isLoading={isLoading}
editActionSecrets={editActionSecrets}
editActionConfig={editActionConfig}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
/>
)}
{!isOldConnector && <InstallationCallout />}
{isOldConnector && <DeprecatedCallout onMigrate={onMigrateClick} />}
<Credentials
action={action}
errors={errors}
readOnly={readOnly}
isLoading={isLoading}
editActionSecrets={editActionSecrets}
editActionConfig={editActionConfig}
/>
{applicationRequired && !isOldConnector && (
<ApplicationRequiredCallout message={applicationInfoErrorMsg} />
)}
</>
);
};

View file

@ -31,6 +31,8 @@ const actionParams = {
category: 'software',
subcategory: 'os',
externalId: null,
correlation_id: 'alertID',
correlation_display: 'Alerting',
},
comments: [],
},
@ -144,7 +146,10 @@ describe('ServiceNowITSMParamsFields renders', () => {
};
mount(<ServiceNowITSMParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual({
incident: {},
incident: {
correlation_display: 'Alerting',
correlation_id: '{{rule.id}}:{{alert.id}}',
},
comments: [],
});
});
@ -166,7 +171,10 @@ describe('ServiceNowITSMParamsFields renders', () => {
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
expect(editAction.mock.calls.length).toEqual(1);
expect(editAction.mock.calls[0][1]).toEqual({
incident: {},
incident: {
correlation_display: 'Alerting',
correlation_id: '{{rule.id}}:{{alert.id}}',
},
comments: [],
});
});

View file

@ -13,16 +13,18 @@ import {
EuiFlexItem,
EuiSpacer,
EuiTitle,
EuiSwitch,
} from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionParamsProps } from '../../../../types';
import { ServiceNowITSMActionParams, Choice, Fields } from './types';
import { ServiceNowITSMActionParams, Choice, Fields, ServiceNowActionConnector } from './types';
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables';
import { useGetChoices } from './use_get_choices';
import { choicesToEuiOptions } from './helpers';
import { choicesToEuiOptions, isLegacyConnector } from './helpers';
import * as i18n from './translations';
import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config';
const useGetChoicesFields = ['urgency', 'severity', 'impact', 'category', 'subcategory'];
const defaultFields: Fields = {
@ -42,6 +44,8 @@ const ServiceNowParamsFields: React.FunctionComponent<
notifications: { toasts },
} = useKibana().services;
const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector);
const actionConnectorRef = useRef(actionConnector?.id ?? '');
const { incident, comments } = useMemo(
() =>
@ -53,8 +57,13 @@ const ServiceNowParamsFields: React.FunctionComponent<
[actionParams.subActionParams]
);
const hasUpdateIncident =
incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE;
const [updateIncident, setUpdateIncident] = useState<boolean>(hasUpdateIncident);
const [choices, setChoices] = useState<Fields>(defaultFields);
const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE;
const editSubActionProperty = useCallback(
(key: string, value: any) => {
const newProps =
@ -90,6 +99,14 @@ const ServiceNowParamsFields: React.FunctionComponent<
);
}, []);
const onUpdateIncidentSwitchChange = useCallback(() => {
const newCorrelationID = !updateIncident
? UPDATE_INCIDENT_VARIABLE
: NOT_UPDATE_INCIDENT_VARIABLE;
editSubActionProperty('correlation_id', newCorrelationID);
setUpdateIncident(!updateIncident);
}, [editSubActionProperty, updateIncident]);
const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]);
const urgencyOptions = useMemo(() => choicesToEuiOptions(choices.urgency), [choices.urgency]);
const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]);
@ -119,7 +136,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
editAction(
'subActionParams',
{
incident: {},
incident: { correlation_id: correlationID, correlation_display: 'Alerting' },
comments: [],
},
index
@ -136,7 +153,7 @@ const ServiceNowParamsFields: React.FunctionComponent<
editAction(
'subActionParams',
{
incident: {},
incident: { correlation_id: correlationID, correlation_display: 'Alerting' },
comments: [],
},
index
@ -236,25 +253,43 @@ const ServiceNowParamsFields: React.FunctionComponent<
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiFormRow
fullWidth
error={errors['subActionParams.incident.short_description']}
isInvalid={
errors['subActionParams.incident.short_description'] !== undefined &&
errors['subActionParams.incident.short_description'].length > 0 &&
incident.short_description !== undefined
}
label={i18n.SHORT_DESCRIPTION_LABEL}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'short_description'}
inputTargetValue={incident?.short_description ?? undefined}
errors={errors['subActionParams.incident.short_description'] as string[]}
/>
</EuiFormRow>
<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
fullWidth
error={errors['subActionParams.incident.short_description']}
isInvalid={
errors['subActionParams.incident.short_description'] !== undefined &&
errors['subActionParams.incident.short_description'].length > 0 &&
incident.short_description !== undefined
}
label={i18n.SHORT_DESCRIPTION_LABEL}
>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'short_description'}
inputTargetValue={incident?.short_description ?? undefined}
errors={errors['subActionParams.incident.short_description'] as string[]}
/>
</EuiFormRow>
</EuiFlexItem>
{!isOldConnector && (
<EuiFlexItem>
<EuiFormRow id="update-incident-form-row" fullWidth label={i18n.UPDATE_INCIDENT_LABEL}>
<EuiSwitch
label={updateIncident ? i18n.ON : i18n.OFF}
name="update-incident-switch"
checked={updateIncident}
onChange={onUpdateIncidentSwitchChange}
aria-describedby="update-incident-form-row"
/>
</EuiFormRow>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="m" />
<TextAreaWithMessageVariables
index={index}
editAction={editSubActionProperty}

View file

@ -33,6 +33,8 @@ const actionParams = {
priority: '1',
subcategory: '20',
externalId: null,
correlation_id: 'alertID',
correlation_display: 'Alerting',
},
comments: [],
},
@ -174,7 +176,10 @@ describe('ServiceNowSIRParamsFields renders', () => {
};
mount(<ServiceNowSIRParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual({
incident: {},
incident: {
correlation_display: 'Alerting',
correlation_id: '{{rule.id}}:{{alert.id}}',
},
comments: [],
});
});
@ -196,7 +201,10 @@ describe('ServiceNowSIRParamsFields renders', () => {
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
expect(editAction.mock.calls.length).toEqual(1);
expect(editAction.mock.calls[0][1]).toEqual({
incident: {},
incident: {
correlation_display: 'Alerting',
correlation_id: '{{rule.id}}:{{alert.id}}',
},
comments: [],
});
});

View file

@ -13,6 +13,7 @@ import {
EuiFlexItem,
EuiSpacer,
EuiTitle,
EuiSwitch,
} from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionParamsProps } from '../../../../types';
@ -21,8 +22,9 @@ import { TextFieldWithMessageVariables } from '../../text_field_with_message_var
import * as i18n from './translations';
import { useGetChoices } from './use_get_choices';
import { ServiceNowSIRActionParams, Fields, Choice } from './types';
import { choicesToEuiOptions } from './helpers';
import { ServiceNowSIRActionParams, Fields, Choice, ServiceNowActionConnector } from './types';
import { choicesToEuiOptions, isLegacyConnector } from './helpers';
import { UPDATE_INCIDENT_VARIABLE, NOT_UPDATE_INCIDENT_VARIABLE } from './config';
const useGetChoicesFields = ['category', 'subcategory', 'priority'];
const defaultFields: Fields = {
@ -31,6 +33,14 @@ const defaultFields: Fields = {
priority: [],
};
const valuesToString = (value: string | string[] | null): string | undefined => {
if (Array.isArray(value)) {
return value.join(',');
}
return value ?? undefined;
};
const ServiceNowSIRParamsFields: React.FunctionComponent<
ActionParamsProps<ServiceNowSIRActionParams>
> = ({ actionConnector, actionParams, editAction, index, errors, messageVariables }) => {
@ -39,6 +49,8 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
notifications: { toasts },
} = useKibana().services;
const isOldConnector = isLegacyConnector(actionConnector as unknown as ServiceNowActionConnector);
const actionConnectorRef = useRef(actionConnector?.id ?? '');
const { incident, comments } = useMemo(
() =>
@ -50,8 +62,13 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
[actionParams.subActionParams]
);
const hasUpdateIncident =
incident.correlation_id != null && incident.correlation_id === UPDATE_INCIDENT_VARIABLE;
const [updateIncident, setUpdateIncident] = useState<boolean>(hasUpdateIncident);
const [choices, setChoices] = useState<Fields>(defaultFields);
const correlationID = updateIncident ? UPDATE_INCIDENT_VARIABLE : NOT_UPDATE_INCIDENT_VARIABLE;
const editSubActionProperty = useCallback(
(key: string, value: any) => {
const newProps =
@ -87,6 +104,14 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
);
}, []);
const onUpdateIncidentSwitchChange = useCallback(() => {
const newCorrelationID = !updateIncident
? UPDATE_INCIDENT_VARIABLE
: NOT_UPDATE_INCIDENT_VARIABLE;
editSubActionProperty('correlation_id', newCorrelationID);
setUpdateIncident(!updateIncident);
}, [editSubActionProperty, updateIncident]);
const { isLoading: isLoadingChoices } = useGetChoices({
http,
toastNotifications: toasts,
@ -115,7 +140,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
editAction(
'subActionParams',
{
incident: {},
incident: { correlation_id: correlationID, correlation_display: 'Alerting' },
comments: [],
},
index
@ -132,7 +157,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
editAction(
'subActionParams',
{
incident: {},
incident: { correlation_id: correlationID, correlation_display: 'Alerting' },
comments: [],
},
index
@ -162,48 +187,48 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'short_description'}
inputTargetValue={incident?.short_description ?? undefined}
inputTargetValue={incident?.short_description}
errors={errors['subActionParams.incident.short_description'] as string[]}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow fullWidth label={i18n.SOURCE_IP_LABEL}>
<EuiFormRow fullWidth label={i18n.SOURCE_IP_LABEL} helpText={i18n.SOURCE_IP_HELP_TEXT}>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'source_ip'}
inputTargetValue={incident?.source_ip ?? undefined}
inputTargetValue={valuesToString(incident?.source_ip)}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow fullWidth label={i18n.DEST_IP_LABEL}>
<EuiFormRow fullWidth label={i18n.DEST_IP_LABEL} helpText={i18n.DEST_IP_HELP_TEXT}>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'dest_ip'}
inputTargetValue={incident?.dest_ip ?? undefined}
inputTargetValue={valuesToString(incident?.dest_ip)}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow fullWidth label={i18n.MALWARE_URL_LABEL}>
<EuiFormRow fullWidth label={i18n.MALWARE_URL_LABEL} helpText={i18n.MALWARE_URL_HELP_TEXT}>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'malware_url'}
inputTargetValue={incident?.malware_url ?? undefined}
inputTargetValue={valuesToString(incident?.malware_url)}
/>
</EuiFormRow>
<EuiSpacer size="m" />
<EuiFormRow fullWidth label={i18n.MALWARE_HASH_LABEL}>
<EuiFormRow fullWidth label={i18n.MALWARE_HASH_LABEL} helpText={i18n.MALWARE_HASH_HELP_TEXT}>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'malware_hash'}
inputTargetValue={incident?.malware_hash ?? undefined}
inputTargetValue={valuesToString(incident?.malware_hash)}
/>
</EuiFormRow>
<EuiSpacer size="m" />
@ -277,6 +302,18 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
inputTargetValue={comments && comments.length > 0 ? comments[0].comment : undefined}
label={i18n.COMMENTS_LABEL}
/>
<EuiSpacer size="m" />
{!isOldConnector && (
<EuiFormRow id="update-incident-form-row" fullWidth label={i18n.UPDATE_INCIDENT_LABEL}>
<EuiSwitch
label={updateIncident ? i18n.ON : i18n.OFF}
name="update-incident-switch"
checked={updateIncident}
onChange={onUpdateIncidentSwitchChange}
aria-describedby="update-incident-form-row"
/>
</EuiFormRow>
)}
</>
);
};

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { SNStoreButton } from './sn_store_button';
describe('SNStoreButton', () => {
test('it renders the button', () => {
render(<SNStoreButton color="warning" />);
expect(screen.getByText('Visit ServiceNow app store')).toBeInTheDocument();
});
test('it renders a danger button', () => {
render(<SNStoreButton color="danger" />);
expect(screen.getByRole('link')).toHaveClass('euiButton--danger');
});
test('it renders with correct href', () => {
render(<SNStoreButton color="warning" />);
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://store.servicenow.com/');
});
});

View file

@ -0,0 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import { EuiButtonProps, EuiButton } from '@elastic/eui';
import * as i18n from './translations';
const STORE_URL = 'https://store.servicenow.com/';
interface Props {
color: EuiButtonProps['color'];
}
const SNStoreButtonComponent: React.FC<Props> = ({ color }) => {
return (
<EuiButton href={STORE_URL} color={color} iconSide="right" iconType="popout">
{i18n.VISIT_SN_STORE}
</EuiButton>
);
};
export const SNStoreButton = memo(SNStoreButtonComponent);

View file

@ -10,7 +10,14 @@ import { i18n } from '@kbn/i18n';
export const API_URL_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlTextFieldLabel',
{
defaultMessage: 'URL',
defaultMessage: 'ServiceNow instance URL',
}
);
export const API_URL_HELPTEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiUrlHelpText',
{
defaultMessage: 'Include the full URL',
}
);
@ -53,7 +60,7 @@ export const REMEMBER_VALUES_LABEL = i18n.translate(
export const REENTER_VALUES_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.reenterValuesLabel',
{
defaultMessage: 'Username and password are encrypted. Please reenter values for these fields.',
defaultMessage: 'You will need to re-authenticate each time you edit the connector',
}
);
@ -95,14 +102,28 @@ export const TITLE_REQUIRED = i18n.translate(
export const SOURCE_IP_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPTitle',
{
defaultMessage: 'Source IP',
defaultMessage: 'Source IPs',
}
);
export const SOURCE_IP_HELP_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceIPHelpText',
{
defaultMessage: 'List of source IPs (comma, or pipe delimited)',
}
);
export const DEST_IP_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destinationIPTitle',
{
defaultMessage: 'Destination IP',
defaultMessage: 'Destination IPs',
}
);
export const DEST_IP_HELP_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.destIPHelpText',
{
defaultMessage: 'List of destination IPs (comma, or pipe delimited)',
}
);
@ -137,14 +158,28 @@ export const COMMENTS_LABEL = i18n.translate(
export const MALWARE_URL_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLTitle',
{
defaultMessage: 'Malware URL',
defaultMessage: 'Malware URLs',
}
);
export const MALWARE_URL_HELP_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareURLHelpText',
{
defaultMessage: 'List of malware URLs (comma, or pipe delimited)',
}
);
export const MALWARE_HASH_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle',
{
defaultMessage: 'Malware Hash',
defaultMessage: 'Malware Hashes',
}
);
export const MALWARE_HASH_HELP_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashHelpText',
{
defaultMessage: 'List of malware hashes (comma, or pipe delimited)',
}
);
@ -196,3 +231,91 @@ export const PRIORITY_LABEL = i18n.translate(
defaultMessage: 'Priority',
}
);
export const API_INFO_ERROR = (status: number) =>
i18n.translate('xpack.triggersActionsUI.components.builtinActionTypes.servicenow.apiInfoError', {
values: { status },
defaultMessage: 'Received status: {status} when attempting to get application information',
});
export const INSTALL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.install',
{
defaultMessage: 'install',
}
);
export const INSTALLATION_CALLOUT_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutTitle',
{
defaultMessage:
'To use this connector, you must first install the Elastic App from the ServiceNow App Store',
}
);
export const MIGRATION_SUCCESS_TOAST_TITLE = (connectorName: string) =>
i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.migrationSuccessToastTitle',
{
defaultMessage: 'Migrated connector {connectorName}',
values: {
connectorName,
},
}
);
export const MIGRATION_SUCCESS_TOAST_TEXT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.installationCalloutText',
{
defaultMessage: 'Connector has been successfully migrated.',
}
);
export const VISIT_SN_STORE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.visitSNStore',
{
defaultMessage: 'Visit ServiceNow app store',
}
);
export const SETUP_DEV_INSTANCE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.setupDevInstance',
{
defaultMessage: 'setup a developer instance',
}
);
export const SN_INSTANCE_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.snInstanceLabel',
{
defaultMessage: 'ServiceNow instance',
}
);
export const UNKNOWN = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.unknown',
{
defaultMessage: 'UNKNOWN',
}
);
export const UPDATE_INCIDENT_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentCheckboxLabel',
{
defaultMessage: 'Update incident',
}
);
export const ON = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOn',
{
defaultMessage: 'On',
}
);
export const OFF = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.updateIncidentOff',
{
defaultMessage: 'Off',
}
);

View file

@ -29,6 +29,7 @@ export interface ServiceNowSIRActionParams {
export interface ServiceNowConfig {
apiUrl: string;
isLegacy: boolean;
}
export interface ServiceNowSecrets {
@ -44,3 +45,17 @@ export interface Choice {
}
export type Fields = Record<string, Choice[]>;
export interface AppInfo {
id: string;
name: string;
scope: string;
version: string;
}
export interface RESTApiError {
error: {
message: string;
detail: string;
};
status: string;
}

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { memo } from 'react';
import {
EuiButton,
EuiButtonEmpty,
EuiFlexGroup,
EuiFlexItem,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiCallOut,
EuiTextColor,
EuiHorizontalRule,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ActionConnectorFieldsProps } from '../../../../../public/types';
import { ServiceNowActionConnector } from './types';
import { Credentials } from './credentials';
import { isFieldInvalid } from './helpers';
import { ApplicationRequiredCallout } from './application_required_callout';
const title = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmationModalTitle',
{
defaultMessage: 'Update ServiceNow connector',
}
);
const cancelButtonText = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.cancelButtonText',
{
defaultMessage: 'Cancel',
}
);
const confirmButtonText = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.confirmButtonText',
{
defaultMessage: 'Update',
}
);
const calloutTitle = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalCalloutTitle',
{
defaultMessage:
'The Elastic App from the ServiceNow App Store must be installed prior to running the update.',
}
);
const warningMessage = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNow.modalWarningMessage',
{
defaultMessage: 'This will update all instances of this connector. This can not be reversed.',
}
);
interface Props {
action: ActionConnectorFieldsProps<ServiceNowActionConnector>['action'];
applicationInfoErrorMsg: string | null;
errors: ActionConnectorFieldsProps<ServiceNowActionConnector>['errors'];
isLoading: boolean;
readOnly: boolean;
editActionSecrets: ActionConnectorFieldsProps<ServiceNowActionConnector>['editActionSecrets'];
editActionConfig: ActionConnectorFieldsProps<ServiceNowActionConnector>['editActionConfig'];
onCancel: () => void;
onConfirm: () => void;
}
const UpdateConnectorModalComponent: React.FC<Props> = ({
action,
applicationInfoErrorMsg,
errors,
isLoading,
readOnly,
editActionSecrets,
editActionConfig,
onCancel,
onConfirm,
}) => {
const { apiUrl } = action.config;
const { username, password } = action.secrets;
const hasErrorsOrEmptyFields =
apiUrl === undefined ||
username === undefined ||
password === undefined ||
isFieldInvalid(apiUrl, errors.apiUrl) ||
isFieldInvalid(username, errors.username) ||
isFieldInvalid(password, errors.password);
return (
<EuiModal onClose={onCancel}>
<EuiModalHeader>
<EuiModalHeaderTitle>
<h1>{title}</h1>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup>
<EuiFlexItem>
<EuiCallOut
size="m"
iconType="alert"
data-test-subj="snModalInstallationCallout"
title={calloutTitle}
/>
</EuiFlexItem>
</EuiFlexGroup>
<Credentials
action={action}
errors={errors}
readOnly={readOnly}
isLoading={isLoading}
editActionSecrets={editActionSecrets}
editActionConfig={editActionConfig}
/>
<EuiHorizontalRule />
<EuiFlexGroup>
<EuiFlexItem>
<EuiTextColor color="danger">{warningMessage}</EuiTextColor>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFlexGroup>
<EuiFlexItem>
{applicationInfoErrorMsg && (
<ApplicationRequiredCallout message={applicationInfoErrorMsg} />
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={onCancel}>{cancelButtonText}</EuiButtonEmpty>
<EuiButton
onClick={onConfirm}
color="danger"
fill
disabled={hasErrorsOrEmptyFields}
isLoading={isLoading}
>
{confirmButtonText}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};
export const UpdateConnectorModal = memo(UpdateConnectorModalComponent);

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { renderHook, act } from '@testing-library/react-hooks';
import { useGetAppInfo, UseGetAppInfo, UseGetAppInfoProps } from './use_get_app_info';
import { getAppInfo } from './api';
import { ServiceNowActionConnector } from './types';
jest.mock('./api');
jest.mock('../../../../common/lib/kibana');
const getAppInfoMock = getAppInfo as jest.Mock;
const actionTypeId = '.servicenow';
const applicationInfoData = {
name: 'Elastic',
scope: 'x_elas2_inc_int',
version: '1.0.0',
};
const actionConnector = {
secrets: {
username: 'user',
password: 'pass',
},
id: 'test',
actionTypeId: '.servicenow',
name: 'ServiceNow ITSM',
isPreconfigured: false,
config: {
apiUrl: 'https://test.service-now.com/',
isLegacy: false,
},
} as ServiceNowActionConnector;
describe('useGetAppInfo', () => {
getAppInfoMock.mockResolvedValue(applicationInfoData);
beforeEach(() => {
jest.clearAllMocks();
});
it('init', async () => {
const { result } = renderHook<UseGetAppInfoProps, UseGetAppInfo>(() =>
useGetAppInfo({
actionTypeId,
})
);
expect(result.current).toEqual({
isLoading: false,
fetchAppInfo: result.current.fetchAppInfo,
});
});
it('returns the application information', async () => {
const { result } = renderHook<UseGetAppInfoProps, UseGetAppInfo>(() =>
useGetAppInfo({
actionTypeId,
})
);
let res;
await act(async () => {
res = await result.current.fetchAppInfo(actionConnector);
});
expect(res).toEqual(applicationInfoData);
});
it('it throws an error when api fails', async () => {
expect.assertions(1);
getAppInfoMock.mockImplementation(() => {
throw new Error('An error occurred');
});
const { result } = renderHook<UseGetAppInfoProps, UseGetAppInfo>(() =>
useGetAppInfo({
actionTypeId,
})
);
await expect(() =>
act(async () => {
await result.current.fetchAppInfo(actionConnector);
})
).rejects.toThrow('An error occurred');
});
});

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { getAppInfo } from './api';
import { AppInfo, RESTApiError, ServiceNowActionConnector } from './types';
export interface UseGetAppInfoProps {
actionTypeId: string;
}
export interface UseGetAppInfo {
fetchAppInfo: (connector: ServiceNowActionConnector) => Promise<AppInfo | RESTApiError>;
isLoading: boolean;
}
export const useGetAppInfo = ({ actionTypeId }: UseGetAppInfoProps): UseGetAppInfo => {
const [isLoading, setIsLoading] = useState(false);
const didCancel = useRef(false);
const abortCtrl = useRef(new AbortController());
const fetchAppInfo = useCallback(
async (connector) => {
try {
didCancel.current = false;
abortCtrl.current.abort();
abortCtrl.current = new AbortController();
setIsLoading(true);
const res = await getAppInfo({
signal: abortCtrl.current.signal,
apiUrl: connector.config.apiUrl,
username: connector.secrets.username,
password: connector.secrets.password,
actionTypeId,
});
if (!didCancel.current) {
setIsLoading(false);
}
return res;
} catch (error) {
if (!didCancel.current) {
setIsLoading(false);
}
throw error;
}
},
[actionTypeId]
);
useEffect(() => {
return () => {
didCancel.current = true;
abortCtrl.current.abort();
setIsLoading(false);
};
}, []);
return {
fetchAppInfo,
isLoading,
};
};

View file

@ -30,6 +30,8 @@ describe('SlackActionFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
@ -56,6 +58,8 @@ describe('SlackActionFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
@ -76,6 +80,8 @@ describe('SlackActionFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);
@ -98,6 +104,8 @@ describe('SlackActionFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);

View file

@ -39,29 +39,28 @@ describe('Swimlane API', () => {
});
it('returns an error when the response fails', async () => {
expect.assertions(1);
const abortCtrl = new AbortController();
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => getApplicationResponse,
});
try {
await getApplication({
await expect(() =>
getApplication({
signal: abortCtrl.signal,
apiToken: '',
appId: '',
url: '',
});
} catch (e) {
expect(e.message).toContain('Received status:');
}
})
).rejects.toThrow('Received status:');
});
it('returns an error when parsing the json fails', async () => {
const abortCtrl = new AbortController();
expect.assertions(1);
const abortCtrl = new AbortController();
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
@ -70,16 +69,14 @@ describe('Swimlane API', () => {
},
});
try {
await getApplication({
await expect(() =>
getApplication({
signal: abortCtrl.signal,
apiToken: '',
appId: '',
url: '',
});
} catch (e) {
expect(e.message).toContain('bad');
}
})
).rejects.toThrow('bad');
});
it('it removes unsafe fields', async () => {

View file

@ -50,6 +50,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
@ -77,6 +79,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
@ -106,6 +110,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
@ -139,6 +145,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
@ -184,6 +192,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
@ -229,6 +239,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
@ -285,6 +297,8 @@ describe('SwimlaneActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);

View file

@ -30,6 +30,8 @@ describe('TeamsActionFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
@ -56,6 +58,8 @@ describe('TeamsActionFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
@ -79,6 +83,8 @@ describe('TeamsActionFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
@ -103,6 +109,8 @@ describe('TeamsActionFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);

View file

@ -35,6 +35,8 @@ describe('WebhookActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy();
@ -62,6 +64,8 @@ describe('WebhookActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="rememberValuesMessage"]').length).toBeGreaterThan(0);
@ -92,6 +96,8 @@ describe('WebhookActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="reenterValuesMessage"]').length).toBeGreaterThan(0);
@ -123,6 +129,8 @@ describe('WebhookActionConnectorFields renders', () => {
editActionConfig={() => {}}
editActionSecrets={() => {}}
readOnly={false}
setCallbacks={() => {}}
isEdit={false}
/>
);
expect(wrapper.find('[data-test-subj="missingSecretsMessage"]').length).toBeGreaterThan(0);

View file

@ -49,6 +49,8 @@ describe('action_connector_form', () => {
dispatch={() => {}}
errors={{ name: [] }}
actionTypeRegistry={actionTypeRegistry}
setCallbacks={() => {}}
isEdit={false}
/>
);
const connectorNameField = wrapper?.find('[data-test-subj="nameInput"]');

Some files were not shown because too many files have changed in this diff Show more