[Cases][Connectors] ServiceNow ITOM: MVP (#114125)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christos Nasikas 2021-10-19 19:39:51 +03:00 committed by GitHub
parent 83f12a9d82
commit 20b11c9f43
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 1956 additions and 80 deletions

View file

@ -43,6 +43,10 @@ a| <<servicenow-sir-action-type, ServiceNow SecOps>>
| Create a security incident in ServiceNow.
a| <<servicenow-itom-action-type, ServiceNow ITOM>>
| Create an event in ServiceNow.
a| <<slack-action-type, Slack>>
| Send a message to a Slack channel or user.

View file

@ -0,0 +1,90 @@
[role="xpack"]
[[servicenow-itom-action-type]]
=== ServiceNow connector and action
++++
<titleabbrev>ServiceNow ITOM</titleabbrev>
++++
The ServiceNow ITOM connector uses the https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html[Event API] to create ServiceNow events.
[float]
[[servicenow-itom-connector-configuration]]
==== Connector configuration
ServiceNow ITOM 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 Event 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-itom-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-itom-configuration]]
==== Preconfigured connector type
[source,text]
--
my-servicenow-itom:
name: preconfigured-servicenow-connector-type
actionTypeId: .servicenow-itom
config:
apiUrl: https://example.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-itom-ui]]
==== Define connector in Stack Management
Define ServiceNow ITOM connector properties.
[role="screenshot"]
image::management/connectors/images/servicenow-itom-connector.png[ServiceNow ITOM connector]
Test ServiceNow ITOM action parameters.
[role="screenshot"]
image::management/connectors/images/servicenow-itom-params-test.png[ServiceNow ITOM params test]
[float]
[[servicenow-itom-action-configuration]]
==== Action configuration
ServiceNow ITOM actions have the following configuration properties.
Source:: The name of the event source type.
Node:: The Host that the event was triggered for.
Type:: The type of event.
Resource:: The name of the resource.
Metric name:: Name of the metric.
Source instance (event_class):: Specific instance of the source.
Message key:: All actions sharing this key will be associated with the same ServiceNow alert. Default value: `<rule ID>:<alert instance ID>`.
Severity:: The severity of the event.
Description:: The details about the event.
Refer to https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html[ServiceNow documentation] for more information about the properties.
[float]
[[configuring-servicenow-itom]]
==== Configure ServiceNow ITOM
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

@ -36,7 +36,7 @@ Use the <<action-settings, Action configuration settings>> to customize connecto
name: preconfigured-servicenow-connector-type
actionTypeId: .servicenow-sir
config:
apiUrl: https://dev94428.service-now.com/
apiUrl: https://example.service-now.com/
secrets:
username: testuser
password: passwordkeystorevalue

View file

@ -36,7 +36,7 @@ Use the <<action-settings, Action configuration settings>> to customize connecto
name: preconfigured-servicenow-connector-type
actionTypeId: .servicenow
config:
apiUrl: https://dev94428.service-now.com/
apiUrl: https://example.service-now.com/
secrets:
username: testuser
password: passwordkeystorevalue

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

View file

@ -7,6 +7,7 @@ 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/servicenow-itom.asciidoc[]
include::action-types/swimlane.asciidoc[]
include::action-types/slack.asciidoc[]
include::action-types/webhook.asciidoc[]

View file

@ -45,9 +45,12 @@ Table of Contents
- [`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)
- [ServiceNow ITOM](#servicenow-itom)
- [`params`](#params-2)
- [`subActionParams (addEvent)`](#subactionparams-addevent)
- [`subActionParams (getChoices)`](#subactionparams-getchoices-2)
- [Jira](#jira)
- [`params`](#params-3)
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-2)
- [`subActionParams (getIncident)`](#subactionparams-getincident-2)
- [`subActionParams (issueTypes)`](#subactionparams-issuetypes)
@ -56,13 +59,13 @@ Table of Contents
- [`subActionParams (issue)`](#subactionparams-issue)
- [`subActionParams (getFields)`](#subactionparams-getfields-2)
- [IBM Resilient](#ibm-resilient)
- [`params`](#params-3)
- [`params`](#params-4)
- [`subActionParams (pushToService)`](#subactionparams-pushtoservice-3)
- [`subActionParams (getFields)`](#subactionparams-getfields-3)
- [`subActionParams (incidentTypes)`](#subactionparams-incidenttypes)
- [`subActionParams (severity)`](#subactionparams-severity)
- [Swimlane](#swimlane)
- [`params`](#params-4)
- [`params`](#params-5)
- [| 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)
@ -355,6 +358,43 @@ No parameters for the `getFields` subaction. Provide an empty object `{}`.
| Property | Description | Type |
| -------- | ---------------------------------------------------- | -------- |
| fields | An array of fields. Example: `[priority, category]`. | string[] |
---
## ServiceNow ITOM
The [ServiceNow ITOM user documentation `params`](https://www.elastic.co/guide/en/kibana/master/servicenow-itom-action-type.html) lists configuration properties for the `addEvent` subaction. In addition, several other subaction types are available.
### `params`
| Property | Description | Type |
| --------------- | ----------------------------------------------------------------- | ------ |
| subAction | The subaction to perform. It can be `addEvent`, and `getChoices`. | string |
| subActionParams | The parameters of the subaction. | object |
#### `subActionParams (addEvent)`
| Property | Description | Type |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
| source | The name of the event source type. | string _(optional)_ |
| event_class | Specific instance of the source. | string _(optional)_ |
| resource | The name of the resource. | string _(optional)_ |
| node | The Host that the event was triggered for. | string _(optional)_ |
| metric_name | Name of the metric. | string _(optional)_ |
| type | The type of event. | string _(optional)_ |
| severity | The category in ServiceNow. | string _(optional)_ |
| description | The subcategory in ServiceNow. | string _(optional)_ |
| additional_info | Any additional information about the event. | string _(optional)_ |
| message_key | This value is used for de-duplication of events. All actions sharing this key will be associated with the same ServiceNow alert. | string _(optional)_ |
| time_of_event | The time of the event. | string _(optional)_ |
Refer to [ServiceNow documentation](https://docs.servicenow.com/bundle/rome-it-operations-management/page/product/event-management/task/send-events-via-web-service.html) for more information about the properties.
#### `subActionParams (getChoices)`
| Property | Description | Type |
| -------- | ------------------------------------------ | -------- |
| fields | An array of fields. Example: `[severity]`. | string[] |
---
## Jira
@ -418,6 +458,7 @@ No parameters for the `issueTypes` subaction. Provide an empty object `{}`.
No parameters for the `getFields` subaction. Provide an empty object `{}`.
---
## IBM Resilient
The [IBM Resilient user documentation `params`](https://www.elastic.co/guide/en/kibana/master/resilient-action-type.html) lists configuration properties for the `pushToService` subaction. In addition, several other subaction types are available.
@ -545,4 +586,4 @@ Instead of `schema.maybe()`, use `schema.nullable()`, which is the same as `sche
## user interface
To make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui).
To make this action usable in the Kibana UI, you will need to provide all the UI editing aspects of the action. The existing action type user interfaces are defined in [`x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types`](../triggers_actions_ui/public/application/components/builtin_action_types). For more information, see the [UI documentation](../triggers_actions_ui/README.md#create-and-register-new-action-type-ui).

View file

@ -16,10 +16,15 @@ import { getActionType as getSwimlaneActionType } from './swimlane';
import { getActionType as getServerLogActionType } from './server_log';
import { getActionType as getSlackActionType } from './slack';
import { getActionType as getWebhookActionType } from './webhook';
import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow';
import {
getServiceNowITSMActionType,
getServiceNowSIRActionType,
getServiceNowITOMActionType,
} from './servicenow';
import { getActionType as getJiraActionType } from './jira';
import { getActionType as getResilientActionType } from './resilient';
import { getActionType as getTeamsActionType } from './teams';
import { ENABLE_ITOM } from '../constants/connectors';
export { ActionParamsType as EmailActionParams, ActionTypeId as EmailActionTypeId } from './email';
export {
ActionParamsType as IndexActionParams,
@ -42,6 +47,7 @@ export {
ActionParamsType as ServiceNowActionParams,
ServiceNowITSMActionTypeId,
ServiceNowSIRActionTypeId,
ServiceNowITOMActionTypeId,
} from './servicenow';
export { ActionParamsType as JiraActionParams, ActionTypeId as JiraActionTypeId } from './jira';
export {
@ -75,4 +81,9 @@ export function registerBuiltInActionTypes({
actionTypeRegistry.register(getJiraActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getResilientActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getTeamsActionType({ logger, configurationUtilities }));
// TODO: Remove when ITOM is ready
if (ENABLE_ITOM) {
actionTypeRegistry.register(getServiceNowITOMActionType({ logger, configurationUtilities }));
}
}

View file

@ -361,6 +361,7 @@ describe('api', () => {
const res = await api.getFields({
externalService,
params: {},
logger: mockedLogger,
});
expect(res).toEqual(serviceNowCommonFields);
});
@ -371,6 +372,7 @@ describe('api', () => {
const res = await api.getChoices({
externalService,
params: { fields: ['priority'] },
logger: mockedLogger,
});
expect(res).toEqual(serviceNowChoices);
});
@ -383,6 +385,7 @@ describe('api', () => {
params: {
externalId: 'incident-1',
},
logger: mockedLogger,
});
expect(res).toEqual({
description: 'description from servicenow',

View file

@ -0,0 +1,53 @@
/*
* 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 { externalServiceITOMMock, itomEventParams } from './mocks';
import { ExternalServiceITOM } from './types';
import { apiITOM, prepareParams } from './api_itom';
let mockedLogger: jest.Mocked<Logger>;
describe('api_itom', () => {
let externalService: jest.Mocked<ExternalServiceITOM>;
const eventParamsWithFormattedDate = {
...itomEventParams,
time_of_event: '2021-10-13, 10:51:44',
};
beforeEach(() => {
externalService = externalServiceITOMMock.create();
jest.clearAllMocks();
});
describe('prepareParams', () => {
test('it prepares the params correctly', async () => {
expect(prepareParams(itomEventParams)).toEqual(eventParamsWithFormattedDate);
});
test('it removes null values', async () => {
const { time_of_event: timeOfEvent, ...rest } = itomEventParams;
expect(prepareParams({ ...rest, time_of_event: null })).toEqual(rest);
});
test('it set the time to null if it is not a proper date', async () => {
const { time_of_event: timeOfEvent, ...rest } = itomEventParams;
expect(prepareParams({ ...rest, time_of_event: 'not a proper date' })).toEqual(rest);
});
});
describe('addEvent', () => {
test('it adds an event correctly', async () => {
await apiITOM.addEvent({
externalService,
params: itomEventParams,
logger: mockedLogger,
});
expect(externalService.addEvent).toHaveBeenCalledWith(eventParamsWithFormattedDate);
});
});
});

View file

@ -0,0 +1,70 @@
/*
* 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 { api } from './api';
import {
ExecutorSubActionAddEventParams,
AddEventApiHandlerArgs,
ExternalServiceApiITOM,
} from './types';
const isValidDate = (d: Date) => !isNaN(d.valueOf());
const formatTimeOfEvent = (timeOfEvent: string | null): string | undefined => {
if (timeOfEvent != null) {
const date = new Date(timeOfEvent);
return isValidDate(date)
? // The format is: yyyy-MM-dd HH:mm:ss GMT
date.toLocaleDateString('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
hour12: false,
minute: '2-digit',
second: '2-digit',
timeZone: 'GMT',
})
: undefined;
}
};
const removeNullValues = (
params: ExecutorSubActionAddEventParams
): ExecutorSubActionAddEventParams =>
(Object.keys(params) as Array<keyof ExecutorSubActionAddEventParams>).reduce(
(acc, key) => ({
...acc,
...(params[key] != null ? { [key]: params[key] } : {}),
}),
{} as ExecutorSubActionAddEventParams
);
export const prepareParams = (
params: ExecutorSubActionAddEventParams
): ExecutorSubActionAddEventParams => {
const timeOfEvent = formatTimeOfEvent(params.time_of_event);
return removeNullValues({
...params,
time_of_event: timeOfEvent ?? null,
});
};
const addEventServiceHandler = async ({
externalService,
params,
}: AddEventApiHandlerArgs): Promise<void> => {
const itomExternalService = externalService;
const preparedParams = prepareParams(params);
await itomExternalService.addEvent(preparedParams);
};
export const apiITOM: ExternalServiceApiITOM = {
getChoices: api.getChoices,
addEvent: addEventServiceHandler,
};

View file

@ -37,4 +37,15 @@ describe('config', () => {
commentFieldKey: 'work_notes',
});
});
test('ITOM: the config are correct', async () => {
const snConfig = snExternalServiceConfig['.servicenow-itom'];
expect(snConfig).toEqual({
importSetTable: 'x_elas2_inc_int_elastic_incident',
appScope: 'x_elas2_inc_int',
table: 'em_event',
useImportAPI: true,
commentFieldKey: 'work_notes',
});
});
});

View file

@ -6,6 +6,7 @@
*/
import {
ENABLE_ITOM,
ENABLE_NEW_SN_ITSM_CONNECTOR,
ENABLE_NEW_SN_SIR_CONNECTOR,
} from '../../constants/connectors';
@ -16,6 +17,7 @@ export const serviceNowSIRTable = 'sn_si_incident';
export const ServiceNowITSMActionTypeId = '.servicenow';
export const ServiceNowSIRActionTypeId = '.servicenow-sir';
export const ServiceNowITOMActionTypeId = '.servicenow-itom';
export const snExternalServiceConfig: SNProductsConfig = {
'.servicenow': {
@ -32,6 +34,14 @@ export const snExternalServiceConfig: SNProductsConfig = {
useImportAPI: ENABLE_NEW_SN_SIR_CONNECTOR,
commentFieldKey: 'work_notes',
},
'.servicenow-itom': {
importSetTable: 'x_elas2_inc_int_elastic_incident',
appScope: 'x_elas2_inc_int',
table: 'em_event',
useImportAPI: ENABLE_ITOM,
commentFieldKey: 'work_notes',
},
};
export const FIELD_PREFIX = 'u_';
export const DEFAULT_ALERTS_GROUPING_KEY = '{{rule.id}}:{{alert.id}}';

View file

@ -8,7 +8,7 @@
import { actionsMock } from '../../mocks';
import { createActionTypeRegistry } from '../index.test';
import {
ServiceNowPublicConfigurationType,
ServiceNowPublicConfigurationBaseType,
ServiceNowSecretConfigurationType,
ExecutorParams,
PushToServiceResponse,
@ -56,7 +56,7 @@ describe('ServiceNow', () => {
beforeAll(() => {
const { actionTypeRegistry } = createActionTypeRegistry();
actionType = actionTypeRegistry.get<
ServiceNowPublicConfigurationType,
ServiceNowPublicConfigurationBaseType,
ServiceNowSecretConfigurationType,
ExecutorParams,
PushToServiceResponse | {}
@ -91,7 +91,7 @@ describe('ServiceNow', () => {
beforeAll(() => {
const { actionTypeRegistry } = createActionTypeRegistry();
actionType = actionTypeRegistry.get<
ServiceNowPublicConfigurationType,
ServiceNowPublicConfigurationBaseType,
ServiceNowSecretConfigurationType,
ExecutorParams,
PushToServiceResponse | {}

View file

@ -11,9 +11,11 @@ import { schema, TypeOf } from '@kbn/config-schema';
import { validate } from './validators';
import {
ExternalIncidentServiceConfiguration,
ExternalIncidentServiceConfigurationBase,
ExternalIncidentServiceSecretConfiguration,
ExecutorParamsSchemaITSM,
ExecutorParamsSchemaSIR,
ExecutorParamsSchemaITOM,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../../types';
@ -32,8 +34,14 @@ import {
ExecutorSubActionGetChoicesParams,
ServiceFactory,
ExternalServiceAPI,
ExecutorParamsITOM,
ExecutorSubActionAddEventParams,
ExternalServiceApiITOM,
ExternalServiceITOM,
ServiceNowPublicConfigurationBaseType,
} from './types';
import {
ServiceNowITOMActionTypeId,
ServiceNowITSMActionTypeId,
serviceNowITSMTable,
ServiceNowSIRActionTypeId,
@ -42,12 +50,16 @@ import {
} from './config';
import { createExternalServiceSIR } from './service_sir';
import { apiSIR } from './api_sir';
import { throwIfSubActionIsNotSupported } from './utils';
import { createExternalServiceITOM } from './service_itom';
import { apiITOM } from './api_itom';
export {
ServiceNowITSMActionTypeId,
serviceNowITSMTable,
ServiceNowSIRActionTypeId,
serviceNowSIRTable,
ServiceNowITOMActionTypeId,
};
export type ActionParamsType =
@ -59,21 +71,20 @@ interface GetActionTypeParams {
configurationUtilities: ActionsConfigurationUtilities;
}
export type ServiceNowActionType = ActionType<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ExecutorParams,
PushToServiceResponse | {}
>;
export type ServiceNowActionType<
C extends Record<string, unknown> = ServiceNowPublicConfigurationBaseType,
T extends Record<string, unknown> = ExecutorParams
> = ActionType<C, ServiceNowSecretConfigurationType, T, PushToServiceResponse | {}>;
export type ServiceNowActionTypeExecutorOptions = ActionTypeExecutorOptions<
ServiceNowPublicConfigurationType,
ServiceNowSecretConfigurationType,
ExecutorParams
>;
export type ServiceNowActionTypeExecutorOptions<
C extends Record<string, unknown> = ServiceNowPublicConfigurationBaseType,
T extends Record<string, unknown> = ExecutorParams
> = ActionTypeExecutorOptions<C, ServiceNowSecretConfigurationType, T>;
// action type definition
export function getServiceNowITSMActionType(params: GetActionTypeParams): ServiceNowActionType {
export function getServiceNowITSMActionType(
params: GetActionTypeParams
): ServiceNowActionType<ServiceNowPublicConfigurationType, ExecutorParams> {
const { logger, configurationUtilities } = params;
return {
id: ServiceNowITSMActionTypeId,
@ -98,7 +109,9 @@ export function getServiceNowITSMActionType(params: GetActionTypeParams): Servic
};
}
export function getServiceNowSIRActionType(params: GetActionTypeParams): ServiceNowActionType {
export function getServiceNowSIRActionType(
params: GetActionTypeParams
): ServiceNowActionType<ServiceNowPublicConfigurationType, ExecutorParams> {
const { logger, configurationUtilities } = params;
return {
id: ServiceNowSIRActionTypeId,
@ -123,6 +136,33 @@ export function getServiceNowSIRActionType(params: GetActionTypeParams): Service
};
}
export function getServiceNowITOMActionType(
params: GetActionTypeParams
): ServiceNowActionType<ServiceNowPublicConfigurationBaseType, ExecutorParamsITOM> {
const { logger, configurationUtilities } = params;
return {
id: ServiceNowITOMActionTypeId,
minimumLicenseRequired: 'platinum',
name: i18n.SERVICENOW_ITOM,
validate: {
config: schema.object(ExternalIncidentServiceConfigurationBase, {
validate: curry(validate.config)(configurationUtilities),
}),
secrets: schema.object(ExternalIncidentServiceSecretConfiguration, {
validate: curry(validate.secrets)(configurationUtilities),
}),
params: ExecutorParamsSchemaITOM,
},
executor: curry(executorITOM)({
logger,
configurationUtilities,
actionTypeId: ServiceNowITOMActionTypeId,
createService: createExternalServiceITOM,
api: apiITOM,
}),
};
}
// action executor
const supportedSubActions: string[] = ['getFields', 'pushToService', 'getChoices', 'getIncident'];
async function executor(
@ -139,7 +179,10 @@ async function executor(
createService: ServiceFactory;
api: ExternalServiceAPI;
},
execOptions: ServiceNowActionTypeExecutorOptions
execOptions: ServiceNowActionTypeExecutorOptions<
ServiceNowPublicConfigurationType,
ExecutorParams
>
): Promise<ActionTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
const { actionId, config, params, secrets } = execOptions;
const { subAction, subActionParams } = params;
@ -156,17 +199,8 @@ async function executor(
externalServiceConfig
);
if (!api[subAction]) {
const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
if (!supportedSubActions.includes(subAction)) {
const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
const apiAsRecord = api as unknown as Record<string, unknown>;
throwIfSubActionIsNotSupported({ api: apiAsRecord, subAction, supportedSubActions, logger });
if (subAction === 'pushToService') {
const pushToServiceParams = subActionParams as ExecutorSubActionPushParams;
@ -187,6 +221,7 @@ async function executor(
data = await api.getFields({
externalService,
params: getFieldsParams,
logger,
});
}
@ -195,6 +230,73 @@ async function executor(
data = await api.getChoices({
externalService,
params: getChoicesParams,
logger,
});
}
return { status: 'ok', data: data ?? {}, actionId };
}
const supportedSubActionsITOM = ['addEvent', 'getChoices'];
async function executorITOM(
{
logger,
configurationUtilities,
actionTypeId,
createService,
api,
}: {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
actionTypeId: string;
createService: ServiceFactory<ExternalServiceITOM>;
api: ExternalServiceApiITOM;
},
execOptions: ServiceNowActionTypeExecutorOptions<
ServiceNowPublicConfigurationBaseType,
ExecutorParamsITOM
>
): Promise<ActionTypeExecutorResult<ServiceNowExecutorResultData | {}>> {
const { actionId, config, params, secrets } = execOptions;
const { subAction, subActionParams } = params;
const externalServiceConfig = snExternalServiceConfig[actionTypeId];
let data: ServiceNowExecutorResultData | null = null;
const externalService = createService(
{
config,
secrets,
},
logger,
configurationUtilities,
externalServiceConfig
) as ExternalServiceITOM;
const apiAsRecord = api as unknown as Record<string, unknown>;
throwIfSubActionIsNotSupported({
api: apiAsRecord,
subAction,
supportedSubActions: supportedSubActionsITOM,
logger,
});
if (subAction === 'addEvent') {
const eventParams = subActionParams as ExecutorSubActionAddEventParams;
await api.addEvent({
externalService,
params: eventParams,
logger,
});
}
if (subAction === 'getChoices') {
const getChoicesParams = subActionParams as ExecutorSubActionGetChoicesParams;
data = await api.getChoices({
externalService,
params: getChoicesParams,
logger,
});
}

View file

@ -12,6 +12,8 @@ import {
ExternalServiceSIR,
Observable,
ObservableTypes,
ExternalServiceITOM,
ExecutorSubActionAddEventParams,
} from './types';
export const serviceNowCommonFields = [
@ -151,6 +153,16 @@ const createSIRMock = (): jest.Mocked<ExternalServiceSIR> => {
return service;
};
const createITOMMock = (): jest.Mocked<ExternalServiceITOM> => {
const serviceMock = createMock();
const service = {
getChoices: serviceMock.getChoices,
addEvent: jest.fn().mockImplementation(() => Promise.resolve()),
};
return service;
};
export const externalServiceMock = {
create: createMock,
};
@ -159,6 +171,10 @@ export const externalServiceSIRMock = {
create: createSIRMock,
};
export const externalServiceITOMMock = {
create: createITOMMock,
};
export const executorParams: ExecutorSubActionPushParams = {
incident: {
externalId: 'incident-3',
@ -227,3 +243,17 @@ export const observables: Observable[] = [
];
export const apiParams = executorParams;
export const itomEventParams: ExecutorSubActionAddEventParams = {
source: 'A source',
event_class: 'An event class',
resource: 'C:',
node: 'node.example.com',
metric_name: 'Percentage Logical Disk Free Space',
type: 'Disk space',
severity: '4',
description: 'desc',
additional_info: '{"alert": "test"}',
message_key: 'a key',
time_of_event: '2021-10-13T10:51:44.981Z',
};

View file

@ -6,12 +6,21 @@
*/
import { schema } from '@kbn/config-schema';
import { DEFAULT_ALERTS_GROUPING_KEY } from './config';
export const ExternalIncidentServiceConfigurationBase = {
apiUrl: schema.string(),
};
export const ExternalIncidentServiceConfiguration = {
apiUrl: schema.string(),
...ExternalIncidentServiceConfigurationBase,
isLegacy: schema.boolean({ defaultValue: false }),
};
export const ExternalIncidentServiceConfigurationBaseSchema = schema.object(
ExternalIncidentServiceConfigurationBase
);
export const ExternalIncidentServiceConfigurationSchema = schema.object(
ExternalIncidentServiceConfiguration
);
@ -80,6 +89,21 @@ export const ExecutorSubActionPushParamsSchemaSIR = schema.object({
comments: CommentsSchema,
});
// Schema for ServiceNow ITOM
export const ExecutorSubActionAddEventParamsSchema = schema.object({
source: schema.nullable(schema.string()),
event_class: schema.nullable(schema.string()),
resource: schema.nullable(schema.string()),
node: schema.nullable(schema.string()),
metric_name: schema.nullable(schema.string()),
type: schema.nullable(schema.string()),
severity: schema.nullable(schema.string()),
description: schema.nullable(schema.string()),
additional_info: schema.nullable(schema.string()),
message_key: schema.nullable(schema.string({ defaultValue: DEFAULT_ALERTS_GROUPING_KEY })),
time_of_event: schema.nullable(schema.string()),
});
export const ExecutorSubActionGetIncidentParamsSchema = schema.object({
externalId: schema.string(),
});
@ -138,3 +162,15 @@ export const ExecutorParamsSchemaSIR = schema.oneOf([
subActionParams: ExecutorSubActionGetChoicesParamsSchema,
}),
]);
// Executor parameters for ITOM
export const ExecutorParamsSchemaITOM = schema.oneOf([
schema.object({
subAction: schema.literal('addEvent'),
subActionParams: ExecutorSubActionAddEventParamsSchema,
}),
schema.object({
subAction: schema.literal('getChoices'),
subActionParams: ExecutorSubActionGetChoicesParamsSchema,
}),
]);

View file

@ -0,0 +1,90 @@
/*
* 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 { createExternalServiceITOM } from './service_itom';
import * as utils from '../lib/axios_utils';
import { ExternalServiceITOM } from './types';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { actionsConfigMock } from '../../actions_config.mock';
import { snExternalServiceConfig } from './config';
import { itomEventParams, serviceNowChoices } from './mocks';
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(),
};
});
axios.create = jest.fn(() => axios);
const requestMock = utils.request as jest.Mock;
const configurationUtilities = actionsConfigMock.create();
describe('ServiceNow SIR service', () => {
let service: ExternalServiceITOM;
beforeEach(() => {
service = createExternalServiceITOM(
{
config: { apiUrl: 'https://example.com/' },
secrets: { username: 'admin', password: 'admin' },
},
logger,
configurationUtilities,
snExternalServiceConfig['.servicenow-itom']
) as ExternalServiceITOM;
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('addEvent', () => {
test('it adds an event', async () => {
requestMock.mockImplementationOnce(() => ({
data: {
result: {
'Default Bulk Endpoint': '1 events were inserted',
},
},
}));
await service.addEvent(itomEventParams);
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/global/em/jsonv2',
method: 'post',
data: { records: [itomEventParams] },
});
});
});
describe('getChoices', () => {
test('it should call request with correct arguments', async () => {
requestMock.mockImplementation(() => ({
data: { result: serviceNowChoices },
}));
await service.getChoices(['severity']);
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
url: 'https://example.com/api/now/table/sys_choice?sysparm_query=name=task^ORname=em_event^element=severity&sysparm_fields=label,value,dependent_value,element',
});
});
});
});

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import axios from 'axios';
import {
ExternalServiceCredentials,
SNProductsConfigValue,
ServiceFactory,
ExternalServiceITOM,
ExecutorSubActionAddEventParams,
} 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 getAddEventURL = (url: string) => `${url}/api/global/em/jsonv2`;
export const createExternalServiceITOM: ServiceFactory<ExternalServiceITOM> = (
credentials: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities,
serviceConfig: SNProductsConfigValue
): ExternalServiceITOM => {
const snService = createExternalService(
credentials,
logger,
configurationUtilities,
serviceConfig
);
const { username, password } = credentials.secrets as ServiceNowSecretConfigurationType;
const axiosInstance = axios.create({
auth: { username, password },
});
const addEvent = async (params: ExecutorSubActionAddEventParams) => {
try {
const res = await request({
axios: axiosInstance,
url: getAddEventURL(snService.getUrl()),
logger,
method: 'post',
data: { records: [params] },
configurationUtilities,
});
snService.checkInstance(res);
} catch (error) {
throw createServiceError(error, `Unable to add event`);
}
};
return {
addEvent,
getChoices: snService.getChoices,
};
};

View file

@ -29,7 +29,7 @@ const getAddObservableToIncidentURL = (url: string, incidentID: string) =>
const getBulkAddObservableToIncidentURL = (url: string, incidentID: string) =>
`${url}/api/x_elas2_sir_int/elastic_api/incident/${incidentID}/observables/bulk`;
export const createExternalServiceSIR: ServiceFactory = (
export const createExternalServiceSIR: ServiceFactory<ExternalServiceSIR> = (
credentials: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities,

View file

@ -19,6 +19,10 @@ export const SERVICENOW_SIR = i18n.translate('xpack.actions.builtin.serviceNowSI
defaultMessage: 'ServiceNow SecOps',
});
export const SERVICENOW_ITOM = i18n.translate('xpack.actions.builtin.serviceNowITOMTitle', {
defaultMessage: 'ServiceNow ITOM',
});
export const ALLOWED_HOSTS_ERROR = (message: string) =>
i18n.translate('xpack.actions.builtin.configuration.apiAllowedHostsError', {
defaultMessage: 'error configuring connector action: {message}',

View file

@ -20,13 +20,21 @@ import {
ExecutorParamsSchemaSIR,
ExecutorSubActionPushParamsSchemaSIR,
ExecutorSubActionGetChoicesParamsSchema,
ExecutorParamsSchemaITOM,
ExecutorSubActionAddEventParamsSchema,
ExternalIncidentServiceConfigurationBaseSchema,
} from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { Logger } from '../../../../../../src/core/server';
export type ServiceNowPublicConfigurationBaseType = TypeOf<
typeof ExternalIncidentServiceConfigurationBaseSchema
>;
export type ServiceNowPublicConfigurationType = TypeOf<
typeof ExternalIncidentServiceConfigurationSchema
>;
export type ServiceNowSecretConfigurationType = TypeOf<
typeof ExternalIncidentServiceSecretConfigurationSchema
>;
@ -108,8 +116,9 @@ export type PushToServiceApiParams = ExecutorSubActionPushParams;
export type PushToServiceApiParamsITSM = ExecutorSubActionPushParamsITSM;
export type PushToServiceApiParamsSIR = ExecutorSubActionPushParamsSIR;
export interface ExternalServiceApiHandlerArgs {
externalService: ExternalService;
export interface ExternalServiceApiHandlerArgs<T = ExternalService> {
externalService: T;
logger: Logger;
}
export type ExecutorSubActionGetIncidentParams = TypeOf<
@ -134,7 +143,6 @@ export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerAr
params: PushToServiceApiParams;
config: Record<string, unknown>;
secrets: Record<string, unknown>;
logger: Logger;
commentFieldKey: string;
}
@ -162,13 +170,13 @@ export interface ExternalServiceChoices {
export type GetCommonFieldsResponse = ExternalServiceFields[];
export type GetChoicesResponse = ExternalServiceChoices[];
export interface GetCommonFieldsHandlerArgs {
externalService: ExternalService;
export interface GetCommonFieldsHandlerArgs extends ExternalServiceApiHandlerArgs {
params: ExecutorSubActionCommonFieldsParams;
}
export interface GetChoicesHandlerArgs {
externalService: ExternalService;
externalService: Partial<ExternalService> & { getChoices: ExternalService['getChoices'] };
logger: Logger;
params: ExecutorSubActionGetChoicesParams;
}
@ -276,9 +284,36 @@ export interface ExternalServiceSIR extends ExternalService {
) => Promise<ObservableResponse[]>;
}
export type ServiceFactory = (
export type ServiceFactory<T = ExternalService> = (
credentials: ExternalServiceCredentials,
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities,
serviceConfig: SNProductsConfigValue
) => ExternalServiceSIR | ExternalService;
) => T;
/**
* ITOM
*/
export type ExecutorSubActionAddEventParams = TypeOf<typeof ExecutorSubActionAddEventParamsSchema>;
export interface ExternalServiceITOM {
getChoices: ExternalService['getChoices'];
addEvent: (params: ExecutorSubActionAddEventParams) => Promise<void>;
}
export interface AddEventApiHandlerArgs extends ExternalServiceApiHandlerArgs<ExternalServiceITOM> {
params: ExecutorSubActionAddEventParams;
}
export interface GetCommonFieldsHandlerArgsITOM
extends ExternalServiceApiHandlerArgs<ExternalServiceITOM> {
params: ExecutorSubActionGetChoicesParams;
}
export interface ExternalServiceApiITOM {
getChoices: ExternalServiceAPI['getChoices'];
addEvent: (args: AddEventApiHandlerArgs) => Promise<void>;
}
export type ExecutorParamsITOM = TypeOf<typeof ExecutorParamsSchemaITOM>;

View file

@ -6,7 +6,17 @@
*/
import { AxiosError } from 'axios';
import { prepareIncident, createServiceError, getPushedDate } from './utils';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import {
prepareIncident,
createServiceError,
getPushedDate,
throwIfSubActionIsNotSupported,
} from './utils';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
/**
* The purpose of this test is to
@ -15,7 +25,6 @@ import { prepareIncident, createServiceError, getPushedDate } from './utils';
* 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 () => {
@ -81,4 +90,45 @@ describe('utils', () => {
expect(getPushedDate()).toBe('2021-10-04T11:15:06.000Z');
});
});
describe('throwIfSubActionIsNotSupported', () => {
const api = { pushToService: 'whatever' };
test('it throws correctly if the subAction is not supported', async () => {
expect.assertions(1);
expect(() =>
throwIfSubActionIsNotSupported({
api,
subAction: 'addEvent',
supportedSubActions: ['getChoices'],
logger,
})
).toThrow('[Action][ExternalService] Unsupported subAction type addEvent');
});
test('it throws correctly if the subAction is not implemented', async () => {
expect.assertions(1);
expect(() =>
throwIfSubActionIsNotSupported({
api,
subAction: 'pushToService',
supportedSubActions: ['getChoices'],
logger,
})
).toThrow('[Action][ExternalService] subAction pushToService not implemented.');
});
test('it does not throw if the sub action is supported and implemented', async () => {
expect(() =>
throwIfSubActionIsNotSupported({
api,
subAction: 'pushToService',
supportedSubActions: ['pushToService'],
logger,
})
).not.toThrow();
});
});
});

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { Logger } from '../../../../../../src/core/server';
import { Incident, PartialIncident, ResponseError, ServiceNowError } from './types';
import { FIELD_PREFIX } from './config';
import { addTimeZoneToDate, getErrorMessage } from '../lib/axios_utils';
@ -44,3 +45,27 @@ export const getPushedDate = (timestamp?: string) => {
return new Date().toISOString();
};
export const throwIfSubActionIsNotSupported = ({
api,
subAction,
supportedSubActions,
logger,
}: {
api: Record<string, unknown>;
subAction: string;
supportedSubActions: string[];
logger: Logger;
}) => {
if (!api[subAction]) {
const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
if (!supportedSubActions.includes(subAction)) {
const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
};

View file

@ -10,3 +10,6 @@ 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;
// TODO: Remove when ready
export const ENABLE_ITOM = true;

View file

@ -5,6 +5,8 @@
* 2.0.
*/
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ENABLE_ITOM } from '../../actions/server/constants/connectors';
import type { TransformConfigSchema } from './transforms/types';
import { ENABLE_CASE_CONNECTOR } from '../../cases/common';
import { METADATA_TRANSFORMS_PATTERN } from './endpoint/constants';
@ -312,6 +314,11 @@ if (ENABLE_CASE_CONNECTOR) {
NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.push('.case');
}
// TODO: Remove when ITOM is ready
if (ENABLE_ITOM) {
NOTIFICATION_SUPPORTED_ACTION_TYPES_IDS.push('.servicenow-itom');
}
export const NOTIFICATION_THROTTLE_NO_ACTIONS = 'no_actions';
export const NOTIFICATION_THROTTLE_RULE = 'rule';

View file

@ -14,10 +14,16 @@ import { getSwimlaneActionType } from './swimlane';
import { getWebhookActionType } from './webhook';
import { TypeRegistry } from '../../type_registry';
import { ActionTypeModel } from '../../../types';
import { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow';
import {
getServiceNowITSMActionType,
getServiceNowSIRActionType,
getServiceNowITOMActionType,
} from './servicenow';
import { getJiraActionType } from './jira';
import { getResilientActionType } from './resilient';
import { getTeamsActionType } from './teams';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { ENABLE_ITOM } from '../../../../../actions/server/constants/connectors';
export function registerBuiltInActionTypes({
actionTypeRegistry,
@ -36,4 +42,9 @@ export function registerBuiltInActionTypes({
actionTypeRegistry.register(getJiraActionType());
actionTypeRegistry.register(getResilientActionType());
actionTypeRegistry.register(getTeamsActionType());
// TODO: Remove when ITOM is ready
if (ENABLE_ITOM) {
actionTypeRegistry.register(getServiceNowITOMActionType());
}
}

View file

@ -35,6 +35,10 @@ describe('helpers', () => {
expect(isFieldInvalid(undefined, ['required'])).toBeFalsy();
});
test('should return if false the field is null', async () => {
expect(isFieldInvalid(null, ['required'])).toBeFalsy();
});
test('should return if false the error is not defined', async () => {
// @ts-expect-error
expect(isFieldInvalid('description', undefined)).toBeFalsy();

View file

@ -23,9 +23,9 @@ 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,
field: string | undefined | null,
error: string | IErrorObject | string[]
): boolean => error !== undefined && error.length > 0 && field !== undefined;
): boolean => error !== undefined && error.length > 0 && field != null;
// TODO: Remove when the applications are certified
export const isLegacyConnector = (connector: ServiceNowActionConnector) => {

View file

@ -5,4 +5,8 @@
* 2.0.
*/
export { getServiceNowITSMActionType, getServiceNowSIRActionType } from './servicenow';
export {
getServiceNowITSMActionType,
getServiceNowSIRActionType,
getServiceNowITOMActionType,
} from './servicenow';

View file

@ -12,6 +12,7 @@ import { ServiceNowActionConnector } from './types';
const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow';
const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir';
const SERVICENOW_ITOM_ACTION_TYPE_ID = '.servicenow-itom';
let actionTypeRegistry: TypeRegistry<ActionTypeModel>;
beforeAll(() => {
@ -20,7 +21,11 @@ beforeAll(() => {
});
describe('actionTypeRegistry.get() works', () => {
[SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => {
[
SERVICENOW_ITSM_ACTION_TYPE_ID,
SERVICENOW_SIR_ACTION_TYPE_ID,
SERVICENOW_ITOM_ACTION_TYPE_ID,
].forEach((id) => {
test(`${id}: action type static data is as expected`, () => {
const actionTypeModel = actionTypeRegistry.get(id);
expect(actionTypeModel.id).toEqual(id);
@ -29,7 +34,11 @@ describe('actionTypeRegistry.get() works', () => {
});
describe('servicenow connector validation', () => {
[SERVICENOW_ITSM_ACTION_TYPE_ID, SERVICENOW_SIR_ACTION_TYPE_ID].forEach((id) => {
[
SERVICENOW_ITSM_ACTION_TYPE_ID,
SERVICENOW_SIR_ACTION_TYPE_ID,
SERVICENOW_ITOM_ACTION_TYPE_ID,
].forEach((id) => {
test(`${id}: connector validation succeeds when connector config is valid`, async () => {
const actionTypeModel = actionTypeRegistry.get(id);
const actionConnector = {
@ -106,7 +115,7 @@ describe('servicenow action params validation', () => {
});
});
test(`${id}: params validation fails when body is not valid`, async () => {
test(`${id}: params validation fails when short_description is not valid`, async () => {
const actionTypeModel = actionTypeRegistry.get(id);
const actionParams = {
subActionParams: { incident: { short_description: '' }, comments: [] },
@ -119,4 +128,22 @@ describe('servicenow action params validation', () => {
});
});
});
test(`${SERVICENOW_ITOM_ACTION_TYPE_ID}: action params validation succeeds when action params is valid`, async () => {
const actionTypeModel = actionTypeRegistry.get(SERVICENOW_ITOM_ACTION_TYPE_ID);
const actionParams = { subActionParams: { severity: 'Critical' } };
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { ['severity']: [] },
});
});
test(`${SERVICENOW_ITOM_ACTION_TYPE_ID}: params validation fails when severity is not valid`, async () => {
const actionTypeModel = actionTypeRegistry.get(SERVICENOW_ITOM_ACTION_TYPE_ID);
const actionParams = { subActionParams: { severity: null } };
expect(await actionTypeModel.validateParams(actionParams)).toEqual({
errors: { ['severity']: ['Severity is required.'] },
});
});
});

View file

@ -18,6 +18,7 @@ import {
ServiceNowSecrets,
ServiceNowITSMActionParams,
ServiceNowSIRActionParams,
ServiceNowITOMActionParams,
} from './types';
import { isValidUrl } from '../../../lib/value_validators';
@ -90,6 +91,20 @@ export const SERVICENOW_SIR_TITLE = i18n.translate(
}
);
export const SERVICENOW_ITOM_TITLE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITOM.actionTypeTitle',
{
defaultMessage: 'ServiceNow ITOM',
}
);
export const SERVICENOW_ITOM_DESC = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowITOM.selectMessageText',
{
defaultMessage: 'Create an event in ServiceNow ITOM.',
}
);
export function getServiceNowITSMActionType(): ActionTypeModel<
ServiceNowConfig,
ServiceNowSecrets,
@ -161,3 +176,34 @@ export function getServiceNowSIRActionType(): ActionTypeModel<
actionParamsFields: lazy(() => import('./servicenow_sir_params')),
};
}
export function getServiceNowITOMActionType(): ActionTypeModel<
ServiceNowConfig,
ServiceNowSecrets,
ServiceNowITOMActionParams
> {
return {
id: '.servicenow-itom',
iconClass: lazy(() => import('./logo')),
selectMessage: SERVICENOW_ITOM_DESC,
actionTypeTitle: SERVICENOW_ITOM_TITLE,
validateConnector,
actionConnectorFields: lazy(() => import('./servicenow_connectors_no_app')),
validateParams: async (
actionParams: ServiceNowITOMActionParams
): Promise<GenericValidationResult<unknown>> => {
const translations = await import('./translations');
const errors = {
severity: new Array<string>(),
};
const validationResult = { errors };
if (actionParams?.subActionParams?.severity == null) {
errors.severity.push(translations.SEVERITY_REQUIRED);
}
return validationResult;
},
actionParamsFields: lazy(() => import('./servicenow_itom_params')),
};
}

View file

@ -0,0 +1,33 @@
/*
* 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 { ActionConnectorFieldsProps } from '../../../../types';
import { ServiceNowActionConnector } from './types';
import { Credentials } from './credentials';
const ServiceNowConnectorFieldsNoApp: React.FC<
ActionConnectorFieldsProps<ServiceNowActionConnector>
> = ({ action, editActionSecrets, editActionConfig, errors, readOnly }) => {
return (
<>
<Credentials
action={action}
errors={errors}
readOnly={readOnly}
isLoading={false}
editActionSecrets={editActionSecrets}
editActionConfig={editActionConfig}
/>
</>
);
};
// eslint-disable-next-line import/no-default-export
export { ServiceNowConnectorFieldsNoApp as default };

View file

@ -0,0 +1,179 @@
/*
* 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 { mount } from 'enzyme';
import { ActionConnector } from '../../../../types';
import { useChoices } from './use_choices';
import ServiceNowITOMParamsFields from './servicenow_itom_params';
jest.mock('./use_choices');
jest.mock('../../../../common/lib/kibana');
const useChoicesMock = useChoices as jest.Mock;
const actionParams = {
subAction: 'addEvent',
subActionParams: {
source: 'A source',
event_class: 'An event class',
resource: 'C:',
node: 'node.example.com',
metric_name: 'Percentage Logical Disk Free Space',
type: 'Disk space',
severity: '4',
description: 'desc',
additional_info: '{"alert": "test"}',
message_key: 'a key',
time_of_event: '2021-10-13T10:51:44.981Z',
},
};
const connector: ActionConnector = {
secrets: {},
config: {},
id: 'test',
actionTypeId: '.test',
name: 'Test',
isPreconfigured: false,
};
const editAction = jest.fn();
const defaultProps = {
actionConnector: connector,
actionParams,
errors: { ['subActionParams.incident.short_description']: [] },
editAction,
index: 0,
messageVariables: [],
};
const choicesResponse = {
isLoading: false,
choices: {
severity: [
{
dependent_value: '',
label: '1 - Critical',
value: '1',
element: 'severity',
},
{
dependent_value: '',
label: '2 - Major',
value: '2',
element: 'severity',
},
],
},
};
describe('ServiceNowITOMParamsFields renders', () => {
beforeEach(() => {
jest.clearAllMocks();
useChoicesMock.mockImplementation((args) => {
return choicesResponse;
});
});
test('all params fields is rendered', () => {
const wrapper = mount(<ServiceNowITOMParamsFields {...defaultProps} />);
expect(wrapper.find('[data-test-subj="sourceInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="nodeInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="typeInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="resourceInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="metric_nameInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="event_classInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="message_keyInput"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="descriptionTextArea"]').exists()).toBeTruthy();
});
test('If severity has errors, form row is invalid', () => {
const newProps = {
...defaultProps,
errors: { severity: ['error'] },
};
const wrapper = mount(<ServiceNowITOMParamsFields {...newProps} />);
const severity = wrapper.find('[data-test-subj="severitySelect"]').first();
expect(severity.prop('isInvalid')).toBeTruthy();
});
test('When subActionParams is undefined, set to default', () => {
const { subActionParams, ...newParams } = actionParams;
const newProps = {
...defaultProps,
actionParams: newParams,
};
mount(<ServiceNowITOMParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual({
message_key: '{{rule.id}}:{{alert.id}}',
additional_info:
'{"alert":{"id":"{{alert.id}}","actionGroup":"{{alert.actionGroup}}","actionSubgroup":"{{alert.actionSubgroup}}","actionGroupName":"{{alert.actionGroupName}}"},"rule":{"id":"{{rule.id}}","name":"{{rule.name}}","type":"{{rule.type}}"},"date":"{{date}}"}',
});
});
test('When subAction is undefined, set to default', () => {
const { subAction, ...newParams } = actionParams;
const newProps = {
...defaultProps,
actionParams: newParams,
};
mount(<ServiceNowITOMParamsFields {...newProps} />);
expect(editAction.mock.calls[0][1]).toEqual('addEvent');
});
test('Resets fields when connector changes', () => {
const wrapper = mount(<ServiceNowITOMParamsFields {...defaultProps} />);
expect(editAction.mock.calls.length).toEqual(0);
wrapper.setProps({ actionConnector: { ...connector, id: '1234' } });
expect(editAction.mock.calls.length).toEqual(1);
expect(editAction.mock.calls[0][1]).toEqual({
message_key: '{{rule.id}}:{{alert.id}}',
additional_info:
'{"alert":{"id":"{{alert.id}}","actionGroup":"{{alert.actionGroup}}","actionSubgroup":"{{alert.actionSubgroup}}","actionGroupName":"{{alert.actionGroupName}}"},"rule":{"id":"{{rule.id}}","name":"{{rule.name}}","type":"{{rule.type}}"},"date":"{{date}}"}',
});
});
test('it transforms the categories to options correctly', async () => {
const wrapper = mount(<ServiceNowITOMParamsFields {...defaultProps} />);
wrapper.update();
expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('options')).toEqual([
{ value: '1', text: '1 - Critical' },
{ value: '2', text: '2 - Major' },
]);
});
describe('UI updates', () => {
const changeEvent = { target: { value: 'Bug' } } as React.ChangeEvent<HTMLSelectElement>;
const simpleFields = [
{ dataTestSubj: 'input[data-test-subj="sourceInput"]', key: 'source' },
{ dataTestSubj: 'textarea[data-test-subj="descriptionTextArea"]', key: 'description' },
{ dataTestSubj: '[data-test-subj="nodeInput"]', key: 'node' },
{ dataTestSubj: '[data-test-subj="typeInput"]', key: 'type' },
{ dataTestSubj: '[data-test-subj="resourceInput"]', key: 'resource' },
{ dataTestSubj: '[data-test-subj="metric_nameInput"]', key: 'metric_name' },
{ dataTestSubj: '[data-test-subj="event_classInput"]', key: 'event_class' },
{ dataTestSubj: '[data-test-subj="message_keyInput"]', key: 'message_key' },
{ dataTestSubj: '[data-test-subj="severitySelect"]', key: 'severity' },
];
simpleFields.forEach((field) =>
test(`${field.key} update triggers editAction :D`, () => {
const wrapper = mount(<ServiceNowITOMParamsFields {...defaultProps} />);
const theField = wrapper.find(field.dataTestSubj).first();
theField.prop('onChange')!(changeEvent);
expect(editAction.mock.calls[0][1][field.key]).toEqual(changeEvent.target.value);
})
);
});
});

View file

@ -0,0 +1,160 @@
/*
* 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, { useCallback, useEffect, useRef, useMemo } from 'react';
import { EuiFormRow, EuiSpacer, EuiTitle, EuiSelect } from '@elastic/eui';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionParamsProps } from '../../../../types';
import { TextAreaWithMessageVariables } from '../../text_area_with_message_variables';
import { TextFieldWithMessageVariables } from '../../text_field_with_message_variables';
import * as i18n from './translations';
import { useChoices } from './use_choices';
import { ServiceNowITOMActionParams } from './types';
import { choicesToEuiOptions, isFieldInvalid } from './helpers';
const choicesFields = ['severity'];
const fields: Array<{
label: string;
fieldKey: keyof ServiceNowITOMActionParams['subActionParams'];
}> = [
{ label: i18n.SOURCE, fieldKey: 'source' },
{ label: i18n.NODE, fieldKey: 'node' },
{ label: i18n.TYPE, fieldKey: 'type' },
{ label: i18n.RESOURCE, fieldKey: 'resource' },
{ label: i18n.METRIC_NAME, fieldKey: 'metric_name' },
{ label: i18n.EVENT_CLASS, fieldKey: 'event_class' },
{ label: i18n.MESSAGE_KEY, fieldKey: 'message_key' },
];
const additionalInformation = JSON.stringify({
alert: {
id: '{{alert.id}}',
actionGroup: '{{alert.actionGroup}}',
actionSubgroup: '{{alert.actionSubgroup}}',
actionGroupName: '{{alert.actionGroupName}}',
},
rule: {
id: '{{rule.id}}',
name: '{{rule.name}}',
type: '{{rule.type}}',
},
date: '{{date}}',
});
const ServiceNowITOMParamsFields: React.FunctionComponent<
ActionParamsProps<ServiceNowITOMActionParams>
> = ({ actionConnector, actionParams, editAction, index, messageVariables, errors }) => {
const params = useMemo(
() => (actionParams.subActionParams ?? {}) as ServiceNowITOMActionParams['subActionParams'],
[actionParams.subActionParams]
);
const { description, severity } = params;
const {
http,
notifications: { toasts },
} = useKibana().services;
const actionConnectorRef = useRef(actionConnector?.id ?? '');
const { choices, isLoading: isLoadingChoices } = useChoices({
http,
toastNotifications: toasts,
actionConnector,
fields: choicesFields,
});
const severityOptions = useMemo(() => choicesToEuiOptions(choices.severity), [choices.severity]);
const editSubActionProperty = useCallback(
(key: string, value: any) => {
editAction('subActionParams', { ...params, [key]: value }, index);
},
[editAction, index, params]
);
useEffect(() => {
if (actionConnector != null && actionConnectorRef.current !== actionConnector.id) {
actionConnectorRef.current = actionConnector.id;
editAction(
'subActionParams',
{ additional_info: additionalInformation, message_key: '{{rule.id}}:{{alert.id}}' },
index
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionConnector]);
useEffect(() => {
if (!actionParams.subAction) {
editAction('subAction', 'addEvent', index);
}
if (!actionParams.subActionParams) {
editAction(
'subActionParams',
{ additional_info: additionalInformation, message_key: '{{rule.id}}:{{alert.id}}' },
index
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [actionParams]);
return (
<>
<EuiTitle size="s">
<h3>{i18n.EVENT}</h3>
</EuiTitle>
<EuiSpacer size="m" />
{fields.map((field) => (
<React.Fragment key={field.fieldKey}>
<EuiFormRow fullWidth label={field.label}>
<TextFieldWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={field.fieldKey}
inputTargetValue={params[field.fieldKey] ?? undefined}
/>
</EuiFormRow>
<EuiSpacer size="m" />
</React.Fragment>
))}
<EuiFormRow
fullWidth
label={i18n.SEVERITY_REQUIRED_LABEL}
error={errors.severity}
isInvalid={isFieldInvalid(severity, errors.severity)}
>
<EuiSelect
fullWidth
hasNoInitialSelection
data-test-subj="severitySelect"
isLoading={isLoadingChoices}
disabled={isLoadingChoices}
options={severityOptions}
value={severity ?? ''}
onChange={(e) => editSubActionProperty('severity', e.target.value)}
isInvalid={isFieldInvalid(severity, errors.severity)}
/>
</EuiFormRow>
<TextAreaWithMessageVariables
index={index}
editAction={editSubActionProperty}
messageVariables={messageVariables}
paramsProperty={'description'}
inputTargetValue={description ?? undefined}
label={i18n.DESCRIPTION_LABEL}
/>
</>
);
};
// eslint-disable-next-line import/no-default-export
export { ServiceNowITOMParamsFields as default };

View file

@ -152,7 +152,7 @@ const ServiceNowSIRParamsFields: React.FunctionComponent<
return (
<>
<EuiTitle size="s">
<h3>{i18n.INCIDENT}</h3>
<h3>{i18n.SECURITY_INCIDENT}</h3>
</EuiTitle>
<EuiSpacer size="m" />
<EuiFormRow

View file

@ -106,6 +106,13 @@ export const INCIDENT = i18n.translate(
}
);
export const SECURITY_INCIDENT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenowSIR.title',
{
defaultMessage: 'Security Incident',
}
);
export const SHORT_DESCRIPTION_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.titleFieldLabel',
{
@ -256,3 +263,76 @@ export const CORRELATION_DISPLAY = i18n.translate(
defaultMessage: 'Correlation display (optional)',
}
);
export const EVENT = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenowITOM.event',
{
defaultMessage: 'Event',
}
);
/**
* ITOM
*/
export const SOURCE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.sourceTextAreaFieldLabel',
{
defaultMessage: 'Source',
}
);
export const EVENT_CLASS = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.eventClassTextAreaFieldLabel',
{
defaultMessage: 'Source instance',
}
);
export const RESOURCE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.resourceTextAreaFieldLabel',
{
defaultMessage: 'Resource',
}
);
export const NODE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.nodeTextAreaFieldLabel',
{
defaultMessage: 'Node',
}
);
export const METRIC_NAME = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.metricNameTextAreaFieldLabel',
{
defaultMessage: 'Metric name',
}
);
export const TYPE = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.typeTextAreaFieldLabel',
{
defaultMessage: 'Type',
}
);
export const MESSAGE_KEY = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.messageKeyTextAreaFieldLabel',
{
defaultMessage: 'Message key',
}
);
export const SEVERITY_REQUIRED = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.requiredSeverityTextField',
{
defaultMessage: 'Severity is required.',
}
);
export const SEVERITY_REQUIRED_LABEL = i18n.translate(
'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.severityRequiredSelectFieldLabel',
{
defaultMessage: 'Severity (required)',
}
);

View file

@ -9,6 +9,7 @@ import { UserConfiguredActionConnector } from '../../../../types';
import {
ExecutorSubActionPushParamsITSM,
ExecutorSubActionPushParamsSIR,
ExecutorSubActionAddEventParams,
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
} from '../../../../../../actions/server/builtin_action_types/servicenow/types';
@ -27,6 +28,11 @@ export interface ServiceNowSIRActionParams {
subActionParams: ExecutorSubActionPushParamsSIR;
}
export interface ServiceNowITOMActionParams {
subAction: string;
subActionParams: ExecutorSubActionAddEventParams;
}
export interface ServiceNowConfig {
apiUrl: string;
isLegacy: boolean;

View file

@ -0,0 +1,164 @@
/*
* 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 } from '@testing-library/react-hooks';
import { useKibana } from '../../../../common/lib/kibana';
import { ActionConnector } from '../../../../types';
import { useChoices, UseChoices, UseChoicesProps } from './use_choices';
import { getChoices } from './api';
jest.mock('./api');
jest.mock('../../../../common/lib/kibana');
const useKibanaMock = useKibana as jest.Mocked<typeof useKibana>;
const getChoicesMock = getChoices as jest.Mock;
const actionConnector = {
secrets: {
username: 'user',
password: 'pass',
},
id: 'test',
actionTypeId: '.servicenow',
name: 'ServiceNow ITSM',
isPreconfigured: false,
config: {
apiUrl: 'https://dev94428.service-now.com/',
},
} as ActionConnector;
const getChoicesResponse = [
{
dependent_value: '',
label: 'Priviledge Escalation',
value: 'Priviledge Escalation',
element: 'category',
},
{
dependent_value: '',
label: 'Criminal activity/investigation',
value: 'Criminal activity/investigation',
element: 'category',
},
{
dependent_value: '',
label: 'Denial of Service',
value: 'Denial of Service',
element: 'category',
},
];
const useChoicesResponse = {
isLoading: false,
choices: { category: getChoicesResponse },
};
describe('UseChoices', () => {
const { services } = useKibanaMock();
getChoicesMock.mockResolvedValue({
data: getChoicesResponse,
});
beforeEach(() => {
jest.clearAllMocks();
});
const fields = ['category'];
it('init', async () => {
const { result, waitForNextUpdate } = renderHook<UseChoicesProps, UseChoices>(() =>
useChoices({
http: services.http,
actionConnector,
toastNotifications: services.notifications.toasts,
fields,
})
);
await waitForNextUpdate();
expect(result.current).toEqual(useChoicesResponse);
});
it('returns an empty array if the field is not in response', async () => {
const { result, waitForNextUpdate } = renderHook<UseChoicesProps, UseChoices>(() =>
useChoices({
http: services.http,
actionConnector,
toastNotifications: services.notifications.toasts,
fields: ['priority'],
})
);
await waitForNextUpdate();
expect(result.current).toEqual({
isLoading: false,
choices: { priority: [], category: getChoicesResponse },
});
});
it('returns an empty array when connector is not presented', async () => {
const { result } = renderHook<UseChoicesProps, UseChoices>(() =>
useChoices({
http: services.http,
actionConnector: undefined,
toastNotifications: services.notifications.toasts,
fields,
})
);
expect(result.current).toEqual({
isLoading: false,
choices: { category: [] },
});
});
it('it displays an error when service fails', async () => {
getChoicesMock.mockResolvedValue({
status: 'error',
service_message: 'An error occurred',
});
const { waitForNextUpdate } = renderHook<UseChoicesProps, UseChoices>(() =>
useChoices({
http: services.http,
actionConnector,
toastNotifications: services.notifications.toasts,
fields,
})
);
await waitForNextUpdate();
expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({
text: 'An error occurred',
title: 'Unable to get choices',
});
});
it('it displays an error when http throws an error', async () => {
getChoicesMock.mockImplementation(() => {
throw new Error('An error occurred');
});
renderHook<UseChoicesProps, UseChoices>(() =>
useChoices({
http: services.http,
actionConnector,
toastNotifications: services.notifications.toasts,
fields,
})
);
expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({
text: 'An error occurred',
title: 'Unable to get choices',
});
});
});

View file

@ -0,0 +1,66 @@
/*
* 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 { useCallback, useMemo, useState } from 'react';
import { HttpSetup, ToastsApi } from 'kibana/public';
import { ActionConnector } from '../../../../types';
import { Choice, Fields } from './types';
import { useGetChoices } from './use_get_choices';
export interface UseChoicesProps {
http: HttpSetup;
toastNotifications: Pick<
ToastsApi,
'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError'
>;
actionConnector?: ActionConnector;
fields: string[];
}
export interface UseChoices {
choices: Fields;
isLoading: boolean;
}
export const useChoices = ({
http,
actionConnector,
toastNotifications,
fields,
}: UseChoicesProps): UseChoices => {
const defaultFields: Record<string, Choice[]> = useMemo(
() => fields.reduce((acc, field) => ({ ...acc, [field]: [] }), {}),
[fields]
);
const [choices, setChoices] = useState<Fields>(defaultFields);
const onChoicesSuccess = useCallback(
(values: Choice[]) => {
setChoices(
values.reduce(
(acc, value) => ({
...acc,
[value.element]: [...(acc[value.element] ?? []), value],
}),
defaultFields
)
);
},
[defaultFields]
);
const { isLoading } = useGetChoices({
http,
toastNotifications,
actionConnector,
fields,
onSuccess: onChoicesSuccess,
});
return { choices, isLoading };
};

View file

@ -121,7 +121,7 @@ describe('useGetChoices', () => {
it('it displays an error when service fails', async () => {
getChoicesMock.mockResolvedValue({
status: 'error',
serviceMessage: 'An error occurred',
service_message: 'An error occurred',
});
const { waitForNextUpdate } = renderHook<UseGetChoicesProps, UseGetChoices>(() =>
@ -162,4 +162,26 @@ describe('useGetChoices', () => {
title: 'Unable to get choices',
});
});
it('returns an empty array if the response is not an array', async () => {
getChoicesMock.mockResolvedValue({
status: 'ok',
data: {},
});
const { result } = renderHook<UseGetChoicesProps, UseGetChoices>(() =>
useGetChoices({
http: services.http,
actionConnector: undefined,
toastNotifications: services.notifications.toasts,
fields,
onSuccess,
})
);
expect(result.current).toEqual({
isLoading: false,
choices: [],
});
});
});

View file

@ -60,15 +60,16 @@ export const useGetChoices = ({
});
if (!didCancel.current) {
const data = Array.isArray(res.data) ? res.data : [];
setIsLoading(false);
setChoices(res.data ?? []);
setChoices(data);
if (res.status && res.status === 'error') {
toastNotifications.addDanger({
title: i18n.CHOICES_API_ERROR,
text: `${res.serviceMessage ?? res.message}`,
text: `${res.service_message ?? res.message}`,
});
} else if (onSuccess) {
onSuccess(res.data ?? []);
onSuccess(data);
}
}
} catch (error) {

View file

@ -35,6 +35,7 @@ const enabledActionTypes = [
'.server-log',
'.servicenow',
'.servicenow-sir',
'.servicenow-itom',
'.jira',
'.resilient',
'.slack',

View file

@ -175,6 +175,14 @@ const handler = async (request: http.IncomingMessage, response: http.ServerRespo
});
}
if (pathName === '/api/global/em/jsonv2') {
return sendResponse(response, {
result: {
'Default Bulk Endpoint': '1 events were inserted',
},
});
}
// Return an 400 error if endpoint is not supported
response.statusCode = 400;
response.setHeader('Content-Type', 'application/json');

View file

@ -231,10 +231,6 @@ export default function jiraTest({ getService }: FtrProviderContext) {
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
expect(resp.body.connector_id).to.eql(simulatedActionId);
expect(resp.body.status).to.eql('error');
expect(resp.body.retry).to.eql(false);
expect(resp.body.message).to.be(
`error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.`
);
});
});

View file

@ -233,10 +233,6 @@ export default function resilientTest({ getService }: FtrProviderContext) {
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
expect(resp.body.connector_id).to.eql(simulatedActionId);
expect(resp.body.status).to.eql('error');
expect(resp.body.retry).to.eql(false);
expect(resp.body.message).to.be(
`error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.`
);
});
});

View file

@ -0,0 +1,342 @@
/*
* 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 httpProxy from 'http-proxy';
import expect from '@kbn/expect';
import getPort from 'get-port';
import http from 'http';
import { getHttpProxyServer } from '../../../../common/lib/get_proxy_server';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { getServiceNowServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin';
// eslint-disable-next-line import/no-default-export
export default function serviceNowITOMTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const configService = getService('config');
const mockServiceNow = {
config: {
apiUrl: 'www.servicenowisinkibanaactions.com',
},
secrets: {
password: 'elastic',
username: 'changeme',
},
params: {
subAction: 'addEvent',
subActionParams: {
source: 'A source',
event_class: 'An event class',
resource: 'C:',
node: 'node.example.com',
metric_name: 'Percentage Logical Disk Free Space',
type: 'Disk space',
severity: '4',
description: 'desc',
additional_info: '{"alert": "test"}',
message_key: 'a key',
time_of_event: '2021-10-13T10:51:44.981Z',
},
},
};
describe('ServiceNow ITOM', () => {
let simulatedActionId = '';
let serviceNowSimulatorURL: string = '';
let serviceNowServer: http.Server;
let proxyServer: httpProxy | undefined;
let proxyHaveBeenCalled = false;
before(async () => {
serviceNowServer = await getServiceNowServer();
const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) });
if (!serviceNowServer.listening) {
serviceNowServer.listen(availablePort);
}
serviceNowSimulatorURL = `http://localhost:${availablePort}`;
proxyServer = await getHttpProxyServer(
serviceNowSimulatorURL,
configService.get('kbnTestServer.serverArgs'),
() => {
proxyHaveBeenCalled = true;
}
);
});
after(() => {
serviceNowServer.close();
if (proxyServer) {
proxyServer.close();
}
});
describe('ServiceNow ITOM - Action Creation', () => {
it('should return 200 when creating a servicenow action successfully', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A servicenow action',
connector_type_id: '.servicenow-itom',
config: {
apiUrl: serviceNowSimulatorURL,
},
secrets: mockServiceNow.secrets,
})
.expect(200);
expect(createdAction).to.eql({
id: createdAction.id,
is_preconfigured: false,
name: 'A servicenow action',
connector_type_id: '.servicenow-itom',
is_missing_secrets: false,
config: {
apiUrl: serviceNowSimulatorURL,
},
});
const { body: fetchedAction } = await supertest
.get(`/api/actions/connector/${createdAction.id}`)
.expect(200);
expect(fetchedAction).to.eql({
id: fetchedAction.id,
is_preconfigured: false,
name: 'A servicenow action',
connector_type_id: '.servicenow-itom',
is_missing_secrets: false,
config: {
apiUrl: serviceNowSimulatorURL,
},
});
});
it('should respond with a 400 Bad Request when creating a servicenow action with no apiUrl', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A servicenow action',
connector_type_id: '.servicenow-itom',
config: {},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]',
});
});
});
it('should respond with a 400 Bad Request when creating a servicenow action with a not present in allowedHosts apiUrl', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A servicenow action',
connector_type_id: '.servicenow-itom',
config: {
apiUrl: 'http://servicenow.mynonexistent.com',
},
secrets: mockServiceNow.secrets,
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type config: error configuring connector action: target url "http://servicenow.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts',
});
});
});
it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A servicenow action',
connector_type_id: '.servicenow-itom',
config: {
apiUrl: serviceNowSimulatorURL,
},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type secrets: [password]: expected value of type [string] but got [undefined]',
});
});
});
});
describe('ServiceNow ITOM - Executor', () => {
before(async () => {
const { body } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A servicenow simulator',
connector_type_id: '.servicenow-itom',
config: {
apiUrl: serviceNowSimulatorURL,
},
secrets: mockServiceNow.secrets,
});
simulatedActionId = body.id;
});
describe('Validation', () => {
it('should handle failing with a simulated success without action', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {},
})
.then((resp: any) => {
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
expect(resp.body.connector_id).to.eql(simulatedActionId);
expect(resp.body.status).to.eql('error');
});
});
it('should handle failing with a simulated success without unsupported action', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'non-supported' },
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subAction]: expected value to equal [getChoices]',
});
});
});
it('should handle failing with a simulated success without subActionParams', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: { subAction: 'pushToService' },
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subAction]: expected value to equal [getChoices]',
});
});
});
describe('getChoices', () => {
it('should fail when field is not provided', async () => {
await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'getChoices',
subActionParams: {},
},
})
.then((resp: any) => {
expect(resp.body).to.eql({
connector_id: simulatedActionId,
status: 'error',
retry: false,
message:
'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [addEvent]\n- [1.subActionParams.fields]: expected value of type [array] but got [undefined]',
});
});
});
});
});
describe('Execution', () => {
// New connectors
describe('Add event', () => {
it('should add an event ', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: mockServiceNow.params,
})
.expect(200);
expect(result.status).to.eql('ok');
});
});
describe('getChoices', () => {
it('should get choices', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'getChoices',
subActionParams: { fields: ['priority'] },
},
})
.expect(200);
expect(proxyHaveBeenCalled).to.equal(true);
expect(result).to.eql({
status: 'ok',
connector_id: simulatedActionId,
data: [
{
dependent_value: '',
label: '1 - Critical',
value: '1',
},
{
dependent_value: '',
label: '2 - High',
value: '2',
},
{
dependent_value: '',
label: '3 - Moderate',
value: '3',
},
{
dependent_value: '',
label: '4 - Low',
value: '4',
},
{
dependent_value: '',
label: '5 - Planning',
value: '5',
},
],
});
});
});
});
});
});
}

View file

@ -241,10 +241,6 @@ export default function serviceNowITSMTest({ getService }: FtrProviderContext) {
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
expect(resp.body.connector_id).to.eql(simulatedActionId);
expect(resp.body.status).to.eql('error');
expect(resp.body.retry).to.eql(false);
expect(resp.body.message).to.be(
`error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.`
);
});
});

View file

@ -245,10 +245,6 @@ export default function serviceNowSIRTest({ getService }: FtrProviderContext) {
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
expect(resp.body.connector_id).to.eql(simulatedActionId);
expect(resp.body.status).to.eql('error');
expect(resp.body.retry).to.eql(false);
expect(resp.body.message).to.be(
`error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined.`
);
});
});

View file

@ -322,10 +322,6 @@ export default function swimlaneTest({ getService }: FtrProviderContext) {
expect(Object.keys(resp.body)).to.eql(['status', 'message', 'retry', 'connector_id']);
expect(resp.body.connector_id).to.eql(simulatedActionId);
expect(resp.body.status).to.eql('error');
expect(resp.body.retry).to.eql(false);
expect(resp.body.message).to.be(
`error validating action params: undefined is not iterable (cannot read property Symbol(Symbol.iterator))`
);
});
});

View file

@ -27,6 +27,7 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo
loadTestFile(require.resolve('./builtin_action_types/server_log'));
loadTestFile(require.resolve('./builtin_action_types/servicenow_itsm'));
loadTestFile(require.resolve('./builtin_action_types/servicenow_sir'));
loadTestFile(require.resolve('./builtin_action_types/servicenow_itom'));
loadTestFile(require.resolve('./builtin_action_types/jira'));
loadTestFile(require.resolve('./builtin_action_types/resilient'));
loadTestFile(require.resolve('./builtin_action_types/slack'));