From a1b3f73e206cc4f59b38586fbb52078ca3d603c4 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 16 Jan 2020 08:08:04 -0700 Subject: [PATCH] [SIEM] [Case] Service Now Kibana Action (#53890) (#54960) --- .../server/builtin_action_types/index.ts | 18 +- .../lib/post_servicenow.ts | 28 ++ .../builtin_action_types/servicenow.test.ts | 279 ++++++++++++++++++ .../server/builtin_action_types/servicenow.ts | 169 +++++++++++ .../alerting_api_integration/common/config.ts | 11 +- .../common/fixtures/plugins/actions/index.ts | 13 +- .../plugins/actions/servicenow_simulation.ts | 120 ++++++++ .../builtin_action_types/servicenow.ts | 271 +++++++++++++++++ .../tests/actions/index.ts | 15 +- 9 files changed, 900 insertions(+), 24 deletions(-) create mode 100644 x-pack/legacy/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts create mode 100644 x-pack/legacy/plugins/actions/server/builtin_action_types/servicenow.test.ts create mode 100644 x-pack/legacy/plugins/actions/server/builtin_action_types/servicenow.ts create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts index e0568a36f874..33f2b2179981 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.ts @@ -8,26 +8,28 @@ import { ActionTypeRegistry } from '../action_type_registry'; import { ActionsConfigurationUtilities } from '../actions_config'; import { Logger } from '../../../../../../src/core/server'; -import { getActionType as getServerLogActionType } from './server_log'; -import { getActionType as getSlackActionType } from './slack'; import { getActionType as getEmailActionType } from './email'; import { getActionType as getIndexActionType } from './es_index'; import { getActionType as getPagerDutyActionType } from './pagerduty'; +import { getActionType as getServerLogActionType } from './server_log'; +import { getActionType as getServiceNowActionType } from './servicenow'; +import { getActionType as getSlackActionType } from './slack'; import { getActionType as getWebhookActionType } from './webhook'; export function registerBuiltInActionTypes({ - logger, - actionTypeRegistry, actionsConfigUtils: configurationUtilities, + actionTypeRegistry, + logger, }: { - logger: Logger; - actionTypeRegistry: ActionTypeRegistry; actionsConfigUtils: ActionsConfigurationUtilities; + actionTypeRegistry: ActionTypeRegistry; + logger: Logger; }) { - actionTypeRegistry.register(getServerLogActionType({ logger })); - actionTypeRegistry.register(getSlackActionType({ configurationUtilities })); actionTypeRegistry.register(getEmailActionType({ logger, configurationUtilities })); actionTypeRegistry.register(getIndexActionType({ logger })); actionTypeRegistry.register(getPagerDutyActionType({ logger, configurationUtilities })); + actionTypeRegistry.register(getServerLogActionType({ logger })); + actionTypeRegistry.register(getServiceNowActionType({ configurationUtilities })); + actionTypeRegistry.register(getSlackActionType({ configurationUtilities })); actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities })); } diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts new file mode 100644 index 000000000000..cfd3a9d70dc9 --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/lib/post_servicenow.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import axios, { AxiosResponse } from 'axios'; +import { Services } from '../../types'; +import { ParamsType, SecretsType } from '../servicenow'; + +interface PostServiceNowOptions { + apiUrl: string; + data: ParamsType; + headers: Record; + services?: Services; + secrets: SecretsType; +} + +// post an event to serviceNow +export async function postServiceNow(options: PostServiceNowOptions): Promise { + const { apiUrl, data, headers, secrets } = options; + const axiosOptions = { + headers, + validateStatus: () => true, + auth: secrets, + }; + return axios.post(`${apiUrl}/api/now/v1/table/incident`, data, axiosOptions); +} diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/servicenow.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/servicenow.test.ts new file mode 100644 index 000000000000..a445c6afde4d --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/servicenow.test.ts @@ -0,0 +1,279 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('./lib/post_servicenow', () => ({ + postServiceNow: jest.fn(), +})); + +import { getActionType } from './servicenow'; +import { ActionType, Services, ActionTypeExecutorOptions } from '../types'; +import { validateConfig, validateSecrets, validateParams } from '../lib'; +import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks'; +import { postServiceNow } from './lib/post_servicenow'; +import { createActionTypeRegistry } from './index.test'; +import { configUtilsMock } from '../actions_config.mock'; + +const postServiceNowMock = postServiceNow as jest.Mock; + +const ACTION_TYPE_ID = '.servicenow'; + +const services: Services = { + callCluster: async (path: string, opts: any) => {}, + savedObjectsClient: savedObjectsClientMock.create(), +}; + +let actionType: ActionType; + +const mockServiceNow = { + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + }, + secrets: { + password: 'secret-password', + username: 'secret-username', + }, + params: { + comments: 'hello cool service now incident', + short_description: 'this is a cool service now incident', + }, +}; + +beforeAll(() => { + const { actionTypeRegistry } = createActionTypeRegistry(); + actionType = actionTypeRegistry.get(ACTION_TYPE_ID); +}); + +describe('get()', () => { + test('should return correct action type', () => { + expect(actionType.id).toEqual(ACTION_TYPE_ID); + expect(actionType.name).toEqual('servicenow'); + }); +}); + +describe('validateConfig()', () => { + test('should validate and pass when config is valid', () => { + const { config } = mockServiceNow; + expect(validateConfig(actionType, config)).toEqual(config); + }); + + test('should validate and throw error when config is invalid', () => { + expect(() => { + validateConfig(actionType, { shouldNotBeHere: true }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [apiUrl]: expected value of type [string] but got [undefined]"` + ); + }); + + test('should validate and pass when the servicenow url is whitelisted', () => { + actionType = getActionType({ + configurationUtilities: { + ...configUtilsMock, + ensureWhitelistedUri: url => { + expect(url).toEqual('https://events.servicenow.com/v2/enqueue'); + }, + }, + }); + + expect( + validateConfig(actionType, { apiUrl: 'https://events.servicenow.com/v2/enqueue' }) + ).toEqual({ apiUrl: 'https://events.servicenow.com/v2/enqueue' }); + }); + + test('config validation returns an error if the specified URL isnt whitelisted', () => { + actionType = getActionType({ + configurationUtilities: { + ...configUtilsMock, + ensureWhitelistedUri: _ => { + throw new Error(`target url is not whitelisted`); + }, + }, + }); + + expect(() => { + validateConfig(actionType, { apiUrl: 'https://events.servicenow.com/v2/enqueue' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: error configuring servicenow action: target url is not whitelisted"` + ); + }); +}); + +describe('validateSecrets()', () => { + test('should validate and pass when secrets is valid', () => { + const { secrets } = mockServiceNow; + expect(validateSecrets(actionType, secrets)).toEqual(secrets); + }); + + test('should validate and throw error when secrets is invalid', () => { + expect(() => { + validateSecrets(actionType, { username: false }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [password]: expected value of type [string] but got [undefined]"` + ); + + expect(() => { + validateSecrets(actionType, { username: false, password: 'hello' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type secrets: [username]: expected value of type [string] but got [boolean]"` + ); + }); +}); + +describe('validateParams()', () => { + test('should validate and pass when params is valid', () => { + const { params } = mockServiceNow; + expect(validateParams(actionType, params)).toEqual(params); + }); + + test('should validate and throw error when params is invalid', () => { + expect(() => { + validateParams(actionType, { eventAction: 'ackynollage' }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action params: [short_description]: expected value of type [string] but got [undefined]"` + ); + }); +}); + +describe('execute()', () => { + beforeEach(() => { + postServiceNowMock.mockReset(); + }); + const { config, params, secrets } = mockServiceNow; + test('should succeed with valid params', async () => { + postServiceNowMock.mockImplementation(() => { + return { status: 201, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + const { apiUrl, data, headers } = postServiceNowMock.mock.calls[0][0]; + expect({ apiUrl, data, headers, secrets }).toMatchInlineSnapshot(` + Object { + "apiUrl": "www.servicenowisinkibanaactions.com", + "data": Object { + "comments": "hello cool service now incident", + "short_description": "this is a cool service now incident", + }, + "headers": Object { + "Accept": "application/json", + "Content-Type": "application/json", + }, + "secrets": Object { + "password": "secret-password", + "username": "secret-username", + }, + } + `); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "data": "data-here", + "status": "ok", + } + `); + }); + + test('should fail when postServiceNow throws', async () => { + postServiceNowMock.mockImplementation(() => { + throw new Error('doing some testing'); + }); + + const actionId = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "message": "error posting servicenow event", + "serviceMessage": "doing some testing", + "status": "error", + } + `); + }); + + test('should fail when postServiceNow returns 429', async () => { + postServiceNowMock.mockImplementation(() => { + return { status: 429, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "message": "error posting servicenow event: http status 429, retry later", + "retry": true, + "status": "error", + } + `); + }); + + test('should fail when postServiceNow returns 501', async () => { + postServiceNowMock.mockImplementation(() => { + return { status: 501, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "message": "error posting servicenow event: http status 501, retry later", + "retry": true, + "status": "error", + } + `); + }); + + test('should fail when postServiceNow returns 418', async () => { + postServiceNowMock.mockImplementation(() => { + return { status: 418, data: 'data-here' }; + }); + + const actionId = 'some-action-id'; + const executorOptions: ActionTypeExecutorOptions = { + actionId, + config, + params, + secrets, + services, + }; + const actionResponse = await actionType.executor(executorOptions); + expect(actionResponse).toMatchInlineSnapshot(` + Object { + "actionId": "some-action-id", + "message": "error posting servicenow event: unexpected status 418", + "status": "error", + } + `); + }); +}); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/servicenow.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/servicenow.ts new file mode 100644 index 000000000000..2d5c18207def --- /dev/null +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/servicenow.ts @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { curry } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { + ActionType, + ActionTypeExecutorOptions, + ActionTypeExecutorResult, + ExecutorType, +} from '../types'; +import { ActionsConfigurationUtilities } from '../actions_config'; +import { postServiceNow } from './lib/post_servicenow'; + +// config definition +export type ConfigType = TypeOf; + +const ConfigSchemaProps = { + apiUrl: schema.string(), +}; + +const ConfigSchema = schema.object(ConfigSchemaProps); + +function validateConfig( + configurationUtilities: ActionsConfigurationUtilities, + configObject: ConfigType +) { + if (configObject.apiUrl == null) { + return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiNullError', { + defaultMessage: 'ServiceNow [apiUrl] is required', + }); + } + try { + configurationUtilities.ensureWhitelistedUri(configObject.apiUrl); + } catch (whitelistError) { + return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiWhitelistError', { + defaultMessage: 'error configuring servicenow action: {message}', + values: { + message: whitelistError.message, + }, + }); + } +} +// secrets definition +export type SecretsType = TypeOf; +const SecretsSchemaProps = { + password: schema.string(), + username: schema.string(), +}; + +const SecretsSchema = schema.object(SecretsSchemaProps); + +function validateSecrets( + configurationUtilities: ActionsConfigurationUtilities, + secrets: SecretsType +) { + if (secrets.username == null) { + return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiUserError', { + defaultMessage: 'error configuring servicenow action: no secrets [username] provided', + }); + } + if (secrets.password == null) { + return i18n.translate('xpack.actions.builtin.servicenow.servicenowApiPasswordError', { + defaultMessage: 'error configuring servicenow action: no secrets [password] provided', + }); + } +} + +// params definition + +export type ParamsType = TypeOf; + +const ParamsSchema = schema.object({ + comments: schema.maybe(schema.string()), + short_description: schema.string(), +}); + +// action type definition +export function getActionType({ + configurationUtilities, + executor = serviceNowExecutor, +}: { + configurationUtilities: ActionsConfigurationUtilities; + executor?: ExecutorType; +}): ActionType { + return { + id: '.servicenow', + name: 'servicenow', + validate: { + config: schema.object(ConfigSchemaProps, { + validate: curry(validateConfig)(configurationUtilities), + }), + secrets: schema.object(SecretsSchemaProps, { + validate: curry(validateSecrets)(configurationUtilities), + }), + params: ParamsSchema, + }, + executor, + }; +} + +// action executor + +async function serviceNowExecutor( + execOptions: ActionTypeExecutorOptions +): Promise { + const actionId = execOptions.actionId; + const config = execOptions.config as ConfigType; + const secrets = execOptions.secrets as SecretsType; + const params = execOptions.params as ParamsType; + const headers = { + Accept: 'application/json', + 'Content-Type': 'application/json', + }; + let response; + try { + response = await postServiceNow({ apiUrl: config.apiUrl, data: params, headers, secrets }); + } catch (err) { + const message = i18n.translate('xpack.actions.builtin.servicenow.postingErrorMessage', { + defaultMessage: 'error posting servicenow event', + }); + return { + status: 'error', + actionId, + message, + serviceMessage: err.message, + }; + } + if (response.status === 200 || response.status === 201 || response.status === 204) { + return { + status: 'ok', + actionId, + data: response.data, + }; + } + + if (response.status === 429 || response.status >= 500) { + const message = i18n.translate('xpack.actions.builtin.servicenow.postingRetryErrorMessage', { + defaultMessage: 'error posting servicenow event: http status {status}, retry later', + values: { + status: response.status, + }, + }); + + return { + status: 'error', + actionId, + message, + retry: true, + }; + } + + const message = i18n.translate('xpack.actions.builtin.servicenow.postingUnexpectedErrorMessage', { + defaultMessage: 'error posting servicenow event: unexpected status {status}', + values: { + status: response.status, + }, + }); + + return { + status: 'error', + actionId, + message, + }; +} diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index 8356954073ec..f322945e236b 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -18,17 +18,18 @@ interface CreateTestConfigOptions { // test.not-enabled is specifically not enabled const enabledActionTypes = [ - '.server-log', - '.slack', '.email', '.index', '.pagerduty', + '.server-log', + '.servicenow', + '.slack', '.webhook', - 'test.noop', - 'test.index-record', + 'test.authorization', 'test.failing', + 'test.index-record', + 'test.noop', 'test.rate-limit', - 'test.authorization', ]; // eslint-disable-next-line import/no-default-export diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts index a5a9353d83cb..02adae72db46 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/index.ts @@ -6,16 +6,18 @@ import Hapi from 'hapi'; import { ActionType } from '../../../../../../legacy/plugins/actions'; +import { initPlugin as initPagerduty } from './pagerduty_simulation'; +import { initPlugin as initServiceNow } from './servicenow_simulation'; import { initPlugin as initSlack } from './slack_simulation'; import { initPlugin as initWebhook } from './webhook_simulation'; -import { initPlugin as initPagerduty } from './pagerduty_simulation'; const NAME = 'actions-FTS-external-service-simulators'; export enum ExternalServiceSimulator { + PAGERDUTY = 'pagerduty', + SERVICENOW = 'servicenow', SLACK = 'slack', WEBHOOK = 'webhook', - PAGERDUTY = 'pagerduty', } export function getExternalServiceSimulatorPath(service: ExternalServiceSimulator): string { @@ -23,9 +25,11 @@ export function getExternalServiceSimulatorPath(service: ExternalServiceSimulato } export function getAllExternalServiceSimulatorPaths(): string[] { - return Object.values(ExternalServiceSimulator).map(service => + const allPaths = Object.values(ExternalServiceSimulator).map(service => getExternalServiceSimulatorPath(service) ); + allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v1/table/incident`); + return allPaths; } // eslint-disable-next-line import/no-default-export @@ -67,9 +71,10 @@ export default function(kibana: any) { }, }); + initPagerduty(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.PAGERDUTY)); + initServiceNow(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW)); initSlack(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.SLACK)); initWebhook(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.WEBHOOK)); - initPagerduty(server, getExternalServiceSimulatorPath(ExternalServiceSimulator.PAGERDUTY)); }, }); } diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts new file mode 100644 index 000000000000..f215b6356033 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions/servicenow_simulation.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; +import Hapi from 'hapi'; + +interface ServiceNowRequest extends Hapi.Request { + payload: { + comments: string; + short_description: string; + }; +} +export function initPlugin(server: Hapi.Server, path: string) { + server.route({ + method: 'POST', + path, + options: { + auth: false, + validate: { + options: { abortEarly: false }, + payload: Joi.object().keys({ + comments: Joi.string(), + short_description: Joi.string(), + }), + }, + }, + handler: servicenowHandler, + }); + + server.route({ + method: 'POST', + path: `${path}/api/now/v1/table/incident`, + options: { + auth: false, + validate: { + options: { abortEarly: false }, + payload: Joi.object().keys({ + comments: Joi.string(), + short_description: Joi.string(), + }), + }, + }, + handler: servicenowHandler, + }); +} +// ServiceNow simulator: create a servicenow action pointing here, and you can get +// different responses based on the message posted. See the README.md for +// more info. + +function servicenowHandler(request: ServiceNowRequest, h: any) { + const body = request.payload; + const text = body && body.short_description; + if (text == null) { + return jsonResponse(h, 400, 'bad request to servicenow simulator'); + } + + switch (text) { + case 'success': + return jsonResponse(h, 200, 'Success'); + + case 'created': + return jsonResponse(h, 201, 'Created'); + + case 'no_text': + return jsonResponse(h, 204, 'Success'); + + case 'invalid_payload': + return jsonResponse(h, 400, 'Bad Request'); + + case 'unauthorized': + return jsonResponse(h, 401, 'Unauthorized'); + + case 'forbidden': + return jsonResponse(h, 403, 'Forbidden'); + + case 'not_found': + return jsonResponse(h, 404, 'Not found'); + + case 'not_allowed': + return jsonResponse(h, 405, 'Method not allowed'); + + case 'not_acceptable': + return jsonResponse(h, 406, 'Not acceptable'); + + case 'unsupported': + return jsonResponse(h, 415, 'Unsupported media type'); + + case 'status_500': + return jsonResponse(h, 500, 'simulated servicenow 500 response'); + + case 'rate_limit': + const response = { + retry_after: 1, + ok: false, + error: 'rate_limited', + }; + + return h + .response(response) + .type('application/json') + .header('retry-after', '1') + .code(429); + } + + return jsonResponse(h, 400, 'unknown request to servicenow simulator'); +} + +function jsonResponse(h: any, code: number, object?: any) { + if (object == null) { + return h.response('').code(code); + } + + return h + .response(JSON.stringify(object)) + .type('application/json') + .code(code); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts new file mode 100644 index 000000000000..15662649266a --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/servicenow.ts @@ -0,0 +1,271 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { + getExternalServiceSimulatorPath, + ExternalServiceSimulator, +} from '../../../../common/fixtures/plugins/actions'; + +// node ../scripts/functional_test_runner.js --grep "Actions.servicenddd" --config=test/alerting_api_integration/security_and_spaces/config.ts + +// eslint-disable-next-line import/no-default-export +export default function servicenowTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const kibanaServer = getService('kibanaServer'); + const mockServiceNow = { + config: { + apiUrl: 'www.servicenowisinkibanaactions.com', + }, + secrets: { + password: 'elastic', + username: 'changeme', + }, + params: { + comments: 'hello cool service now incident', + short_description: 'this is a cool service now incident', + }, + }; + describe('servicenow', () => { + let simulatedActionId = ''; + let servicenowSimulatorURL: string = ''; + + // need to wait for kibanaServer to settle ... + before(() => { + servicenowSimulatorURL = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW) + ); + }); + + after(() => esArchiver.unload('empty_kibana')); + + it('should return 200 when creating a servicenow action successfully', async () => { + const { body: createdAction } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + actionTypeId: '.servicenow', + config: { + apiUrl: servicenowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + name: 'A servicenow action', + actionTypeId: '.servicenow', + config: { + apiUrl: servicenowSimulatorURL, + }, + }); + + expect(typeof createdAction.id).to.be('string'); + + const { body: fetchedAction } = await supertest + .get(`/api/action/${createdAction.id}`) + .expect(200); + + expect(fetchedAction).to.eql({ + id: fetchedAction.id, + name: 'A servicenow action', + actionTypeId: '.servicenow', + config: { + apiUrl: servicenowSimulatorURL, + }, + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action with no webhookUrl', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + actionTypeId: '.servicenow', + 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 non whitelisted webhookUrl', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + actionTypeId: '.servicenow', + config: { + apiUrl: 'http://servicenow.mynonexistent.com', + }, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error configuring servicenow action: target url "http://servicenow.mynonexistent.com" is not whitelisted in the Kibana config xpack.actions.whitelistedHosts', + }); + }); + }); + + it('should respond with a 400 Bad Request when creating a servicenow action without secrets', async () => { + await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow action', + actionTypeId: '.servicenow', + 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]', + }); + }); + }); + + it('should create our servicenow simulator action successfully', async () => { + const { body: createdSimulatedAction } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A servicenow simulator', + actionTypeId: '.servicenow', + config: { + apiUrl: servicenowSimulatorURL, + }, + secrets: mockServiceNow.secrets, + }) + .expect(200); + + simulatedActionId = createdSimulatedAction.id; + }); + + it('should handle executing with a simulated success', async () => { + const { body: result } = await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + comments: 'success', + short_description: 'success', + }, + }) + .expect(200); + + expect(result.status).to.eql('ok'); + }); + + it('should handle executing with a simulated success without comments', async () => { + const { body: result } = await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + short_description: 'success', + }, + }) + .expect(200); + + expect(result.status).to.eql('ok'); + }); + + it('should handle failing with a simulated success without short_description', async () => { + await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + comments: 'success', + }, + }) + .then((resp: any) => { + expect(resp.body).to.eql({ + actionId: simulatedActionId, + status: 'error', + retry: false, + message: + 'error validating action params: [short_description]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should handle a 40x servicenow error', async () => { + const { body: result } = await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + comments: 'invalid_payload', + short_description: 'invalid_payload', + }, + }) + .expect(200); + expect(result.status).to.equal('error'); + expect(result.message).to.match(/error posting servicenow event: unexpected status 400/); + }); + + it('should handle a 429 servicenow error', async () => { + const { body: result } = await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + comments: 'rate_limit', + short_description: 'rate_limit', + }, + }) + .expect(200); + + expect(result.status).to.equal('error'); + expect(result.message).to.equal( + 'error posting servicenow event: http status 429, retry later' + ); + expect(result.retry).to.equal(true); + }); + + it('should handle a 500 servicenow error', async () => { + const { body: result } = await supertest + .post(`/api/action/${simulatedActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + comments: 'status_500', + short_description: 'status_500', + }, + }) + .expect(200); + + expect(result.status).to.equal('error'); + expect(result.message).to.equal( + 'error posting servicenow event: http status 500, retry later' + ); + expect(result.retry).to.equal(true); + }); + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts index 7f67f2f5b60e..c6960a4eedd2 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/index.ts @@ -9,18 +9,19 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function actionsTests({ loadTestFile }: FtrProviderContext) { describe('Actions', () => { + loadTestFile(require.resolve('./builtin_action_types/email')); + loadTestFile(require.resolve('./builtin_action_types/es_index')); + loadTestFile(require.resolve('./builtin_action_types/pagerduty')); + loadTestFile(require.resolve('./builtin_action_types/server_log')); + loadTestFile(require.resolve('./builtin_action_types/servicenow')); + loadTestFile(require.resolve('./builtin_action_types/slack')); + loadTestFile(require.resolve('./builtin_action_types/webhook')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./execute')); loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./list_action_types')); loadTestFile(require.resolve('./update')); - loadTestFile(require.resolve('./execute')); - loadTestFile(require.resolve('./builtin_action_types/server_log')); - loadTestFile(require.resolve('./builtin_action_types/slack')); - loadTestFile(require.resolve('./builtin_action_types/email')); - loadTestFile(require.resolve('./builtin_action_types/es_index')); - loadTestFile(require.resolve('./builtin_action_types/pagerduty')); - loadTestFile(require.resolve('./builtin_action_types/webhook')); }); }