Actions add proxy support (#74289)

* Added proxy support for action types

* Fixed tests

* added rejectUnauthorizedCertificates config setting

* removed slack not used code

* Fixed Slack proxy

* fixed typecheck errors

* Cleanup code

* Fixed slack

* Added unit tests

* added proxy server for test

* Fixed build

* Added functional tests

* fixed due to comments

* Fixed tests and some changes due to comments

* Fixed functional tests

* fixed circular deps

* Added proxy unit test to action type
This commit is contained in:
Yuliia Naumenko 2020-08-14 14:20:12 -07:00 committed by GitHub
parent 64b8b88c64
commit 52bd6d98ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 830 additions and 198 deletions

View file

@ -44,9 +44,9 @@
"@storybook/addon-storyshots": "^5.3.19",
"@storybook/react": "^5.3.19",
"@storybook/theming": "^5.3.19",
"@testing-library/jest-dom": "^5.8.0",
"@testing-library/react": "^9.3.2",
"@testing-library/react-hooks": "^3.2.1",
"@testing-library/jest-dom": "^5.8.0",
"@types/angular": "^1.6.56",
"@types/archiver": "^3.1.0",
"@types/base64-js": "^1.2.5",
@ -72,8 +72,9 @@
"@types/gulp": "^4.0.6",
"@types/hapi__wreck": "^15.0.1",
"@types/he": "^1.1.1",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/history": "^4.7.3",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/http-proxy": "^1.17.4",
"@types/jest": "^25.2.3",
"@types/jest-specific-snapshot": "^0.5.4",
"@types/joi": "^13.4.2",
@ -94,6 +95,7 @@
"@types/object-hash": "^1.3.0",
"@types/papaparse": "^5.0.3",
"@types/pngjs": "^3.3.2",
"@types/pretty-ms": "^5.0.0",
"@types/prop-types": "^15.5.3",
"@types/proper-lockfile": "^3.0.1",
"@types/puppeteer": "^1.20.1",
@ -109,6 +111,7 @@
"@types/redux-actions": "^2.6.1",
"@types/set-value": "^2.0.0",
"@types/sinon": "^7.0.13",
"@types/stats-lite": "^2.2.0",
"@types/styled-components": "^5.1.0",
"@types/supertest": "^2.0.5",
"@types/tar-fs": "^1.16.1",
@ -116,11 +119,9 @@
"@types/tinycolor2": "^1.4.1",
"@types/use-resize-observer": "^6.0.0",
"@types/uuid": "^3.4.4",
"@types/webpack-env": "^1.15.2",
"@types/xml-crypto": "^1.4.0",
"@types/xml2js": "^0.4.5",
"@types/stats-lite": "^2.2.0",
"@types/pretty-ms": "^5.0.0",
"@types/webpack-env": "^1.15.2",
"@welldone-software/why-did-you-render": "^4.0.0",
"abab": "^1.0.4",
"autoprefixer": "^9.7.4",
@ -227,6 +228,7 @@
"@turf/circle": "6.0.1",
"@turf/distance": "6.0.1",
"@turf/helpers": "6.0.1",
"@types/http-proxy-agent": "^2.0.2",
"angular": "^1.8.0",
"angular-resource": "1.8.0",
"angular-sanitize": "1.8.0",

View file

@ -9,6 +9,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { TypeOf } from '@kbn/config-schema';
import { Logger } from '../../../../../../src/core/server';
import {
ExternalIncidentServiceConfigurationSchema,
@ -122,7 +123,12 @@ export interface ExternalServiceApi {
export interface CreateExternalServiceBasicArgs {
api: ExternalServiceApi;
createExternalService: (credentials: ExternalServiceCredentials) => ExternalService;
createExternalService: (
credentials: ExternalServiceCredentials,
logger: Logger,
proxySettings?: any
) => ExternalService;
logger: Logger;
}
export interface CreateExternalServiceArgs extends CreateExternalServiceBasicArgs {

View file

@ -67,6 +67,7 @@ export const mapParams = (
export const createConnectorExecutor = ({
api,
createExternalService,
logger,
}: CreateExternalServiceBasicArgs) => async (
execOptions: ActionTypeExecutorOptions<
ExternalIncidentServiceConfiguration,
@ -83,10 +84,14 @@ export const createConnectorExecutor = ({
actionId,
};
const externalService = createExternalService({
config,
secrets,
});
const externalService = createExternalService(
{
config,
secrets,
},
logger,
execOptions.proxySettings
);
if (!api[subAction]) {
throw new Error('[Action][ExternalService] Unsupported subAction type.');
@ -122,10 +127,11 @@ export const createConnector = ({
validate,
createExternalService,
validationSchema,
logger,
}: CreateExternalServiceArgs) => {
return ({
configurationUtilities,
executor = createConnectorExecutor({ api, createExternalService }),
executor = createConnectorExecutor({ api, createExternalService, logger }),
}: CreateActionTypeArgs): ActionType => ({
...config,
validate: {

View file

@ -269,6 +269,7 @@ describe('execute()', () => {
"message": "a message to you",
"subject": "the subject",
},
"proxySettings": undefined,
"routing": Object {
"bcc": Array [
"jimmy@example.com",
@ -326,6 +327,7 @@ describe('execute()', () => {
"message": "a message to you",
"subject": "the subject",
},
"proxySettings": undefined,
"routing": Object {
"bcc": Array [
"jimmy@example.com",

View file

@ -184,6 +184,7 @@ async function executor(
subject: params.subject,
message: params.message,
},
proxySettings: execOptions.proxySettings,
};
let result;

View file

@ -31,9 +31,9 @@ export function registerBuiltInActionTypes({
actionTypeRegistry.register(getIndexActionType({ logger }));
actionTypeRegistry.register(getPagerDutyActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getServerLogActionType({ logger }));
actionTypeRegistry.register(getSlackActionType({ configurationUtilities }));
actionTypeRegistry.register(getSlackActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getWebhookActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getServiceNowActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getJiraActionType({ configurationUtilities }));
actionTypeRegistry.register(getResilientActionType({ configurationUtilities }));
actionTypeRegistry.register(getJiraActionType({ logger, configurationUtilities }));
actionTypeRegistry.register(getResilientActionType({ logger, configurationUtilities }));
}

View file

@ -4,21 +4,33 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Logger } from '../../../../../../src/core/server';
import { createConnector } from '../case/utils';
import { ActionType } from '../../types';
import { api } from './api';
import { config } from './config';
import { validate } from './validators';
import { createExternalService } from './service';
import { JiraSecretConfiguration, JiraPublicConfiguration } from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
export const getActionType = createConnector({
api,
config,
validate,
createExternalService,
validationSchema: {
config: JiraPublicConfiguration,
secrets: JiraSecretConfiguration,
},
});
export function getActionType({
logger,
configurationUtilities,
}: {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
}): ActionType {
return createConnector({
api,
config,
validate,
createExternalService,
validationSchema: {
config: JiraPublicConfiguration,
secrets: JiraSecretConfiguration,
},
logger,
})({ configurationUtilities });
}

View file

@ -9,6 +9,9 @@ import axios from 'axios';
import { createExternalService } from './service';
import * as utils from '../lib/axios_utils';
import { ExternalService } from '../case/types';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
jest.mock('../lib/axios_utils', () => {
@ -26,10 +29,13 @@ describe('Jira service', () => {
let service: ExternalService;
beforeAll(() => {
service = createExternalService({
config: { apiUrl: 'https://siem-kibana.atlassian.net', projectKey: 'CK' },
secrets: { apiToken: 'token', email: 'elastic@elastic.com' },
});
service = createExternalService(
{
config: { apiUrl: 'https://siem-kibana.atlassian.net', projectKey: 'CK' },
secrets: { apiToken: 'token', email: 'elastic@elastic.com' },
},
logger
);
});
beforeEach(() => {
@ -39,37 +45,49 @@ describe('Jira service', () => {
describe('createExternalService', () => {
test('throws without url', () => {
expect(() =>
createExternalService({
config: { apiUrl: null, projectKey: 'CK' },
secrets: { apiToken: 'token', email: 'elastic@elastic.com' },
})
createExternalService(
{
config: { apiUrl: null, projectKey: 'CK' },
secrets: { apiToken: 'token', email: 'elastic@elastic.com' },
},
logger
)
).toThrow();
});
test('throws without projectKey', () => {
expect(() =>
createExternalService({
config: { apiUrl: 'test.com', projectKey: null },
secrets: { apiToken: 'token', email: 'elastic@elastic.com' },
})
createExternalService(
{
config: { apiUrl: 'test.com', projectKey: null },
secrets: { apiToken: 'token', email: 'elastic@elastic.com' },
},
logger
)
).toThrow();
});
test('throws without username', () => {
expect(() =>
createExternalService({
config: { apiUrl: 'test.com' },
secrets: { apiToken: '', email: 'elastic@elastic.com' },
})
createExternalService(
{
config: { apiUrl: 'test.com' },
secrets: { apiToken: '', email: 'elastic@elastic.com' },
},
logger
)
).toThrow();
});
test('throws without password', () => {
expect(() =>
createExternalService({
config: { apiUrl: 'test.com' },
secrets: { apiToken: '', email: undefined },
})
createExternalService(
{
config: { apiUrl: 'test.com' },
secrets: { apiToken: '', email: undefined },
},
logger
)
).toThrow();
});
});
@ -92,6 +110,7 @@ describe('Jira service', () => {
expect(requestMock).toHaveBeenCalledWith({
axios,
url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1',
logger,
});
});
@ -146,6 +165,7 @@ describe('Jira service', () => {
expect(requestMock).toHaveBeenCalledWith({
axios,
url: 'https://siem-kibana.atlassian.net/rest/api/2/issue',
logger,
method: 'post',
data: {
fields: {
@ -210,6 +230,7 @@ describe('Jira service', () => {
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
method: 'put',
url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1',
data: { fields: { summary: 'title', description: 'desc' } },
@ -272,6 +293,7 @@ describe('Jira service', () => {
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
method: 'post',
url: 'https://siem-kibana.atlassian.net/rest/api/2/issue/1/comment',
data: { body: 'comment' },

View file

@ -7,6 +7,7 @@
import axios from 'axios';
import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types';
import { Logger } from '../../../../../../src/core/server';
import {
JiraPublicConfigurationType,
JiraSecretConfigurationType,
@ -17,6 +18,7 @@ import {
import * as i18n from './translations';
import { request, getErrorMessage } from '../lib/axios_utils';
import { ProxySettings } from '../../types';
const VERSION = '2';
const BASE_URL = `rest/api/${VERSION}`;
@ -25,10 +27,11 @@ const COMMENT_URL = `comment`;
const VIEW_INCIDENT_URL = `browse`;
export const createExternalService = ({
config,
secrets,
}: ExternalServiceCredentials): ExternalService => {
export const createExternalService = (
{ config, secrets }: ExternalServiceCredentials,
logger: Logger,
proxySettings?: ProxySettings
): ExternalService => {
const { apiUrl: url, projectKey } = config as JiraPublicConfigurationType;
const { apiToken, email } = secrets as JiraSecretConfigurationType;
@ -55,6 +58,8 @@ export const createExternalService = ({
const res = await request({
axios: axiosInstance,
url: `${incidentUrl}/${id}`,
logger,
proxySettings,
});
const { fields, ...rest } = res.data;
@ -75,10 +80,12 @@ export const createExternalService = ({
const res = await request<CreateIncidentRequest>({
axios: axiosInstance,
url: `${incidentUrl}`,
logger,
method: 'post',
data: {
fields: { ...incident, project: { key: projectKey }, issuetype: { name: 'Task' } },
},
proxySettings,
});
const updatedIncident = await getIncident(res.data.id);
@ -102,7 +109,9 @@ export const createExternalService = ({
axios: axiosInstance,
method: 'put',
url: `${incidentUrl}/${incidentId}`,
logger,
data: { fields: { ...incident } },
proxySettings,
});
const updatedIncident = await getIncident(incidentId);
@ -129,7 +138,9 @@ export const createExternalService = ({
axios: axiosInstance,
method: 'post',
url: getCommentsURL(incidentId),
logger,
data: { body: comment.comment },
proxySettings,
});
return {

View file

@ -5,7 +5,11 @@
*/
import axios from 'axios';
import { addTimeZoneToDate, throwIfNotAlive, request, patch, getErrorMessage } from './axios_utils';
import HttpProxyAgent from 'http-proxy-agent';
import { Logger } from '../../../../../../src/core/server';
import { addTimeZoneToDate, request, patch, getErrorMessage } from './axios_utils';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
const axiosMock = (axios as unknown) as jest.Mock;
@ -21,26 +25,6 @@ describe('addTimeZoneToDate', () => {
});
});
describe('throwIfNotAlive ', () => {
test('throws correctly when status is invalid', async () => {
expect(() => {
throwIfNotAlive(404, 'application/json');
}).toThrow('Instance is not alive.');
});
test('throws correctly when content is invalid', () => {
expect(() => {
throwIfNotAlive(200, 'application/html');
}).toThrow('Instance is not alive.');
});
test('do NOT throws with custom validStatusCodes', async () => {
expect(() => {
throwIfNotAlive(404, 'application/json', [404]);
}).not.toThrow('Instance is not alive.');
});
});
describe('request', () => {
beforeEach(() => {
axiosMock.mockImplementation(() => ({
@ -51,9 +35,50 @@ describe('request', () => {
});
test('it fetch correctly with defaults', async () => {
const res = await request({ axios, url: '/test' });
const res = await request({
axios,
url: '/test',
logger,
});
expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'get', data: {} });
expect(axiosMock).toHaveBeenCalledWith('/test', {
method: 'get',
data: {},
headers: undefined,
httpAgent: undefined,
httpsAgent: undefined,
params: undefined,
proxy: false,
validateStatus: undefined,
});
expect(res).toEqual({
status: 200,
headers: { 'content-type': 'application/json' },
data: { incidentId: '123' },
});
});
test('it have been called with proper proxy agent', async () => {
const res = await request({
axios,
url: '/testProxy',
logger,
proxySettings: {
proxyUrl: 'http://localhost:1212',
rejectUnauthorizedCertificates: false,
},
});
expect(axiosMock).toHaveBeenCalledWith('/testProxy', {
method: 'get',
data: {},
headers: undefined,
httpAgent: new HttpProxyAgent('http://localhost:1212'),
httpsAgent: new HttpProxyAgent('http://localhost:1212'),
params: undefined,
proxy: false,
validateStatus: undefined,
});
expect(res).toEqual({
status: 200,
headers: { 'content-type': 'application/json' },
@ -62,25 +87,24 @@ describe('request', () => {
});
test('it fetch correctly', async () => {
const res = await request({ axios, url: '/test', method: 'post', data: { id: '123' } });
const res = await request({ axios, url: '/test', method: 'post', logger, data: { id: '123' } });
expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'post', data: { id: '123' } });
expect(axiosMock).toHaveBeenCalledWith('/test', {
method: 'post',
data: { id: '123' },
headers: undefined,
httpAgent: undefined,
httpsAgent: undefined,
params: undefined,
proxy: false,
validateStatus: undefined,
});
expect(res).toEqual({
status: 200,
headers: { 'content-type': 'application/json' },
data: { incidentId: '123' },
});
});
test('it throws correctly', async () => {
axiosMock.mockImplementation(() => ({
status: 404,
headers: { 'content-type': 'application/json' },
data: { incidentId: '123' },
}));
await expect(request({ axios, url: '/test' })).rejects.toThrow();
});
});
describe('patch', () => {
@ -92,8 +116,17 @@ describe('patch', () => {
});
test('it fetch correctly', async () => {
await patch({ axios, url: '/test', data: { id: '123' } });
expect(axiosMock).toHaveBeenCalledWith('/test', { method: 'patch', data: { id: '123' } });
await patch({ axios, url: '/test', data: { id: '123' }, logger });
expect(axiosMock).toHaveBeenCalledWith('/test', {
method: 'patch',
data: { id: '123' },
headers: undefined,
httpAgent: undefined,
httpsAgent: undefined,
params: undefined,
proxy: false,
validateStatus: undefined,
});
});
});

View file

@ -4,50 +4,68 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { AxiosInstance, Method, AxiosResponse } from 'axios';
export const throwIfNotAlive = (
status: number,
contentType: string,
validStatusCodes: number[] = [200, 201, 204]
) => {
if (!validStatusCodes.includes(status) || !contentType.includes('application/json')) {
throw new Error('Instance is not alive.');
}
};
import { AxiosInstance, Method, AxiosResponse, AxiosBasicCredentials } from 'axios';
import { Logger } from '../../../../../../src/core/server';
import { ProxySettings } from '../../types';
import { getProxyAgent } from './get_proxy_agent';
export const request = async <T = unknown>({
axios,
url,
logger,
method = 'get',
data,
params,
proxySettings,
headers,
validateStatus,
auth,
}: {
axios: AxiosInstance;
url: string;
logger: Logger;
method?: Method;
data?: T;
params?: unknown;
proxySettings?: ProxySettings;
headers?: Record<string, string> | null;
validateStatus?: (status: number) => boolean;
auth?: AxiosBasicCredentials;
}): Promise<AxiosResponse> => {
const res = await axios(url, { method, data: data ?? {}, params });
throwIfNotAlive(res.status, res.headers['content-type']);
return res;
return await axios(url, {
method,
data: data ?? {},
params,
auth,
// use httpsAgent and embedded proxy: false, to be able to handle fail on invalid certs
httpsAgent: proxySettings ? getProxyAgent(proxySettings, logger) : undefined,
httpAgent: proxySettings ? getProxyAgent(proxySettings, logger) : undefined,
proxy: false, // the same way as it done for IncomingWebhook in
headers,
validateStatus,
});
};
export const patch = async <T = unknown>({
axios,
url,
data,
logger,
proxySettings,
}: {
axios: AxiosInstance;
url: string;
data: T;
logger: Logger;
proxySettings?: ProxySettings;
}): Promise<AxiosResponse> => {
return request({
axios,
url,
logger,
method: 'patch',
data,
proxySettings,
});
};

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import HttpProxyAgent from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { Logger } from '../../../../../../src/core/server';
import { getProxyAgent } from './get_proxy_agent';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
describe('getProxyAgent', () => {
test('return HttpsProxyAgent for https proxy url', () => {
const agent = getProxyAgent(
{ proxyUrl: 'https://someproxyhost', rejectUnauthorizedCertificates: false },
logger
);
expect(agent instanceof HttpsProxyAgent).toBeTruthy();
});
test('return HttpProxyAgent for http proxy url', () => {
const agent = getProxyAgent(
{ proxyUrl: 'http://someproxyhost', rejectUnauthorizedCertificates: false },
logger
);
expect(agent instanceof HttpProxyAgent).toBeTruthy();
});
});

View file

@ -0,0 +1,31 @@
/*
* 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 HttpProxyAgent from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { Logger } from '../../../../../../src/core/server';
import { ProxySettings } from '../../types';
export function getProxyAgent(
proxySettings: ProxySettings,
logger: Logger
): HttpsProxyAgent | HttpProxyAgent {
logger.debug(`Create proxy agent for ${proxySettings.proxyUrl}.`);
if (/^https/i.test(proxySettings.proxyUrl)) {
const proxyUrl = new URL(proxySettings.proxyUrl);
return new HttpsProxyAgent({
host: proxyUrl.hostname,
port: Number(proxyUrl.port),
protocol: proxyUrl.protocol,
headers: proxySettings.proxyHeaders,
// do not fail on invalid certs if value is false
rejectUnauthorized: proxySettings.rejectUnauthorizedCertificates,
});
} else {
return new HttpProxyAgent(proxySettings.proxyUrl);
}
}

View file

@ -5,22 +5,34 @@
*/
import axios, { AxiosResponse } from 'axios';
import { Services } from '../../types';
import { Logger } from '../../../../../../src/core/server';
import { Services, ProxySettings } from '../../types';
import { request } from './axios_utils';
interface PostPagerdutyOptions {
apiUrl: string;
data: unknown;
headers: Record<string, string>;
services: Services;
proxySettings?: ProxySettings;
}
// post an event to pagerduty
export async function postPagerduty(options: PostPagerdutyOptions): Promise<AxiosResponse> {
const { apiUrl, data, headers } = options;
const axiosOptions = {
export async function postPagerduty(
options: PostPagerdutyOptions,
logger: Logger
): Promise<AxiosResponse> {
const { apiUrl, data, headers, proxySettings } = options;
const axiosInstance = axios.create();
return await request({
axios: axiosInstance,
url: apiUrl,
method: 'post',
logger,
data,
proxySettings,
headers,
validateStatus: () => true,
};
return axios.post(apiUrl, data, axiosOptions);
});
}

View file

@ -12,6 +12,7 @@ import { Logger } from '../../../../../../src/core/server';
import { sendEmail } from './send_email';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import nodemailer from 'nodemailer';
import { ProxySettings } from '../../types';
const createTransportMock = nodemailer.createTransport as jest.Mock;
const sendMailMockResult = { result: 'does not matter' };
@ -63,6 +64,59 @@ describe('send_email module', () => {
});
test('handles unauthenticated email using not secure host/port', async () => {
const sendEmailOptions = getSendEmailOptions(
{
transport: {
host: 'example.com',
port: 1025,
},
},
{
proxyUrl: 'https://example.com',
rejectUnauthorizedCertificates: false,
}
);
delete sendEmailOptions.transport.service;
delete sendEmailOptions.transport.user;
delete sendEmailOptions.transport.password;
const result = await sendEmail(mockLogger, sendEmailOptions);
expect(result).toBe(sendMailMockResult);
expect(createTransportMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"headers": undefined,
"host": "example.com",
"port": 1025,
"proxy": "https://example.com",
"secure": false,
"tls": Object {
"rejectUnauthorized": false,
},
},
]
`);
expect(sendMailMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"bcc": Array [],
"cc": Array [
"bob@example.com",
"robert@example.com",
],
"from": "fred@example.com",
"html": "<p>a message</p>
",
"subject": "a subject",
"text": "a message",
"to": Array [
"jim@example.com",
],
},
]
`);
});
test('rejectUnauthorized default setting email using not secure host/port', async () => {
const sendEmailOptions = getSendEmailOptions({
transport: {
host: 'example.com',
@ -80,9 +134,6 @@ describe('send_email module', () => {
"host": "example.com",
"port": 1025,
"secure": false,
"tls": Object {
"rejectUnauthorized": false,
},
},
]
`);
@ -161,7 +212,10 @@ describe('send_email module', () => {
});
});
function getSendEmailOptions({ content = {}, routing = {}, transport = {} } = {}) {
function getSendEmailOptions(
{ content = {}, routing = {}, transport = {} } = {},
proxySettings?: ProxySettings
) {
return {
content: {
...content,
@ -181,5 +235,6 @@ function getSendEmailOptions({ content = {}, routing = {}, transport = {} } = {}
user: 'elastic',
password: 'changeme',
},
proxySettings,
};
}

View file

@ -6,10 +6,10 @@
// info on nodemailer: https://nodemailer.com/about/
import nodemailer from 'nodemailer';
import { default as MarkdownIt } from 'markdown-it';
import { Logger } from '../../../../../../src/core/server';
import { ProxySettings } from '../../types';
// an email "service" which doesn't actually send, just returns what it would send
export const JSON_TRANSPORT_SERVICE = '__json';
@ -18,6 +18,7 @@ export interface SendEmailOptions {
transport: Transport;
routing: Routing;
content: Content;
proxySettings?: ProxySettings;
}
// config validation ensures either service is set or host/port are set
@ -44,7 +45,7 @@ export interface Content {
// send an email
export async function sendEmail(logger: Logger, options: SendEmailOptions): Promise<unknown> {
const { transport, routing, content } = options;
const { transport, routing, content, proxySettings } = options;
const { service, host, port, secure, user, password } = transport;
const { from, to, cc, bcc } = routing;
const { subject, message } = content;
@ -67,11 +68,16 @@ export async function sendEmail(logger: Logger, options: SendEmailOptions): Prom
transportConfig.host = host;
transportConfig.port = port;
transportConfig.secure = !!secure;
if (!transportConfig.secure) {
if (proxySettings && !transportConfig.secure) {
transportConfig.tls = {
rejectUnauthorized: false,
// do not fail on invalid certs if value is false
rejectUnauthorized: proxySettings?.rejectUnauthorizedCertificates,
};
}
if (proxySettings) {
transportConfig.proxy = proxySettings.proxyUrl;
transportConfig.headers = proxySettings.proxyHeaders;
}
}
const nodemailerTransport = nodemailer.createTransport(transportConfig);

View file

@ -161,6 +161,7 @@ async function executor(
const secrets = execOptions.secrets;
const params = execOptions.params;
const services = execOptions.services;
const proxySettings = execOptions.proxySettings;
const apiUrl = getPagerDutyApiUrl(config);
const headers = {
@ -171,7 +172,7 @@ async function executor(
let response;
try {
response = await postPagerduty({ apiUrl, data, headers, services });
response = await postPagerduty({ apiUrl, data, headers, services, proxySettings }, logger);
} catch (err) {
const message = i18n.translate('xpack.actions.builtin.pagerduty.postingErrorMessage', {
defaultMessage: 'error posting pagerduty event',

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Logger } from '../../../../../../src/core/server';
import { createConnector } from '../case/utils';
import { api } from './api';
@ -11,14 +12,25 @@ import { config } from './config';
import { validate } from './validators';
import { createExternalService } from './service';
import { ResilientSecretConfiguration, ResilientPublicConfiguration } from './schema';
import { ActionsConfigurationUtilities } from '../../actions_config';
import { ActionType } from '../../types';
export const getActionType = createConnector({
api,
config,
validate,
createExternalService,
validationSchema: {
config: ResilientPublicConfiguration,
secrets: ResilientSecretConfiguration,
},
});
export function getActionType({
logger,
configurationUtilities,
}: {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
}): ActionType {
return createConnector({
api,
config,
validate,
createExternalService,
validationSchema: {
config: ResilientPublicConfiguration,
secrets: ResilientSecretConfiguration,
},
logger,
})({ configurationUtilities });
}

View file

@ -9,6 +9,9 @@ import axios from 'axios';
import { createExternalService, getValueTextContent, formatUpdateRequest } from './service';
import * as utils from '../lib/axios_utils';
import { ExternalService } from '../case/types';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
jest.mock('../lib/axios_utils', () => {
@ -72,10 +75,13 @@ describe('IBM Resilient service', () => {
let service: ExternalService;
beforeAll(() => {
service = createExternalService({
config: { apiUrl: 'https://resilient.elastic.co', orgId: '201' },
secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' },
});
service = createExternalService(
{
config: { apiUrl: 'https://resilient.elastic.co', orgId: '201' },
secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' },
},
logger
);
});
afterAll(() => {
@ -138,37 +144,49 @@ describe('IBM Resilient service', () => {
describe('createExternalService', () => {
test('throws without url', () => {
expect(() =>
createExternalService({
config: { apiUrl: null, orgId: '201' },
secrets: { apiKeyId: 'token', apiKeySecret: 'secret' },
})
createExternalService(
{
config: { apiUrl: null, orgId: '201' },
secrets: { apiKeyId: 'token', apiKeySecret: 'secret' },
},
logger
)
).toThrow();
});
test('throws without orgId', () => {
expect(() =>
createExternalService({
config: { apiUrl: 'test.com', orgId: null },
secrets: { apiKeyId: 'token', apiKeySecret: 'secret' },
})
createExternalService(
{
config: { apiUrl: 'test.com', orgId: null },
secrets: { apiKeyId: 'token', apiKeySecret: 'secret' },
},
logger
)
).toThrow();
});
test('throws without username', () => {
expect(() =>
createExternalService({
config: { apiUrl: 'test.com', orgId: '201' },
secrets: { apiKeyId: '', apiKeySecret: 'secret' },
})
createExternalService(
{
config: { apiUrl: 'test.com', orgId: '201' },
secrets: { apiKeyId: '', apiKeySecret: 'secret' },
},
logger
)
).toThrow();
});
test('throws without password', () => {
expect(() =>
createExternalService({
config: { apiUrl: 'test.com', orgId: '201' },
secrets: { apiKeyId: '', apiKeySecret: undefined },
})
createExternalService(
{
config: { apiUrl: 'test.com', orgId: '201' },
secrets: { apiKeyId: '', apiKeySecret: undefined },
},
logger
)
).toThrow();
});
});
@ -197,6 +215,7 @@ describe('IBM Resilient service', () => {
await service.getIncident('1');
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1',
params: {
text_content_output_format: 'objects_convert',
@ -256,6 +275,7 @@ describe('IBM Resilient service', () => {
expect(requestMock).toHaveBeenCalledWith({
axios,
url: 'https://resilient.elastic.co/rest/orgs/201/incidents',
logger,
method: 'post',
data: {
name: 'title',
@ -311,6 +331,7 @@ describe('IBM Resilient service', () => {
// The second call to the API is the update call.
expect(requestMock.mock.calls[1][0]).toEqual({
axios,
logger,
method: 'patch',
url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1',
data: {
@ -392,7 +413,9 @@ describe('IBM Resilient service', () => {
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
method: 'post',
proxySettings: undefined,
url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1/comments',
data: {
text: {

View file

@ -6,6 +6,7 @@
import axios from 'axios';
import { Logger } from '../../../../../../src/core/server';
import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from '../case/types';
import {
ResilientPublicConfigurationType,
@ -19,6 +20,7 @@ import {
import * as i18n from './translations';
import { getErrorMessage, request } from '../lib/axios_utils';
import { ProxySettings } from '../../types';
const BASE_URL = `rest`;
const INCIDENT_URL = `incidents`;
@ -57,10 +59,11 @@ export const formatUpdateRequest = ({
};
};
export const createExternalService = ({
config,
secrets,
}: ExternalServiceCredentials): ExternalService => {
export const createExternalService = (
{ config, secrets }: ExternalServiceCredentials,
logger: Logger,
proxySettings?: ProxySettings
): ExternalService => {
const { apiUrl: url, orgId } = config as ResilientPublicConfigurationType;
const { apiKeyId, apiKeySecret } = secrets as ResilientSecretConfigurationType;
@ -88,9 +91,11 @@ export const createExternalService = ({
const res = await request({
axios: axiosInstance,
url: `${incidentUrl}/${id}`,
logger,
params: {
text_content_output_format: 'objects_convert',
},
proxySettings,
});
return { ...res.data, description: res.data.description?.content ?? '' };
@ -107,6 +112,7 @@ export const createExternalService = ({
axios: axiosInstance,
url: `${incidentUrl}`,
method: 'post',
logger,
data: {
...incident,
description: {
@ -115,6 +121,7 @@ export const createExternalService = ({
},
discovered_date: Date.now(),
},
proxySettings,
});
return {
@ -139,7 +146,9 @@ export const createExternalService = ({
axios: axiosInstance,
method: 'patch',
url: `${incidentUrl}/${incidentId}`,
logger,
data,
proxySettings,
});
if (!res.data.success) {
@ -170,7 +179,9 @@ export const createExternalService = ({
axios: axiosInstance,
method: 'post',
url: getCommentsURL(incidentId),
logger,
data: { text: { format: 'text', content: comment.comment } },
proxySettings,
});
return {

View file

@ -76,10 +76,14 @@ async function executor(
const { subAction, subActionParams } = params;
let data: PushToServiceResponse | null = null;
const externalService = createExternalService({
config,
secrets,
});
const externalService = createExternalService(
{
config,
secrets,
},
logger,
execOptions.proxySettings
);
if (!api[subAction]) {
const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`;

View file

@ -9,6 +9,9 @@ import axios from 'axios';
import { createExternalService } from './service';
import * as utils from '../lib/axios_utils';
import { ExternalService } from './types';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
jest.mock('../lib/axios_utils', () => {
@ -28,10 +31,13 @@ describe('ServiceNow service', () => {
let service: ExternalService;
beforeAll(() => {
service = createExternalService({
config: { apiUrl: 'https://dev102283.service-now.com' },
secrets: { username: 'admin', password: 'admin' },
});
service = createExternalService(
{
config: { apiUrl: 'https://dev102283.service-now.com' },
secrets: { username: 'admin', password: 'admin' },
},
logger
);
});
beforeEach(() => {
@ -41,28 +47,37 @@ describe('ServiceNow service', () => {
describe('createExternalService', () => {
test('throws without url', () => {
expect(() =>
createExternalService({
config: { apiUrl: null },
secrets: { username: 'admin', password: 'admin' },
})
createExternalService(
{
config: { apiUrl: null },
secrets: { username: 'admin', password: 'admin' },
},
logger
)
).toThrow();
});
test('throws without username', () => {
expect(() =>
createExternalService({
config: { apiUrl: 'test.com' },
secrets: { username: '', password: 'admin' },
})
createExternalService(
{
config: { apiUrl: 'test.com' },
secrets: { username: '', password: 'admin' },
},
logger
)
).toThrow();
});
test('throws without password', () => {
expect(() =>
createExternalService({
config: { apiUrl: 'test.com' },
secrets: { username: '', password: undefined },
})
createExternalService(
{
config: { apiUrl: 'test.com' },
secrets: { username: '', password: undefined },
},
logger
)
).toThrow();
});
});
@ -84,6 +99,7 @@ describe('ServiceNow service', () => {
await service.getIncident('1');
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1',
});
});
@ -127,6 +143,7 @@ describe('ServiceNow service', () => {
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
url: 'https://dev102283.service-now.com/api/now/v2/table/incident',
method: 'post',
data: { short_description: 'title', description: 'desc' },
@ -179,6 +196,7 @@ describe('ServiceNow service', () => {
expect(patchMock).toHaveBeenCalledWith({
axios,
logger,
url: 'https://dev102283.service-now.com/api/now/v2/table/incident/1',
data: { short_description: 'title', description: 'desc' },
});

View file

@ -9,8 +9,10 @@ import axios from 'axios';
import { ExternalServiceCredentials, ExternalService, ExternalServiceParams } from './types';
import * as i18n from './translations';
import { Logger } from '../../../../../../src/core/server';
import { ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType } from './types';
import { request, getErrorMessage, addTimeZoneToDate, patch } from '../lib/axios_utils';
import { ProxySettings } from '../../types';
const API_VERSION = 'v2';
const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`;
@ -18,10 +20,11 @@ const INCIDENT_URL = `api/now/${API_VERSION}/table/incident`;
// Based on: https://docs.servicenow.com/bundle/orlando-platform-user-interface/page/use/navigation/reference/r_NavigatingByURLExamples.html
const VIEW_INCIDENT_URL = `nav_to.do?uri=incident.do?sys_id=`;
export const createExternalService = ({
config,
secrets,
}: ExternalServiceCredentials): ExternalService => {
export const createExternalService = (
{ config, secrets }: ExternalServiceCredentials,
logger: Logger,
proxySettings?: ProxySettings
): ExternalService => {
const { apiUrl: url } = config as ServiceNowPublicConfigurationType;
const { username, password } = secrets as ServiceNowSecretConfigurationType;
@ -43,6 +46,8 @@ export const createExternalService = ({
const res = await request({
axios: axiosInstance,
url: `${incidentUrl}/${id}`,
logger,
proxySettings,
});
return { ...res.data.result };
@ -58,6 +63,8 @@ export const createExternalService = ({
const res = await request({
axios: axiosInstance,
url: incidentUrl,
logger,
proxySettings,
params,
});
@ -71,9 +78,13 @@ export const createExternalService = ({
const createIncident = async ({ incident }: ExternalServiceParams) => {
try {
logger.warn(`incident error : ${JSON.stringify(proxySettings)}`);
logger.warn(`incident error : ${url}`);
const res = await request({
axios: axiosInstance,
url: `${incidentUrl}`,
logger,
proxySettings,
method: 'post',
data: { ...(incident as Record<string, unknown>) },
});
@ -96,7 +107,9 @@ export const createExternalService = ({
const res = await patch({
axios: axiosInstance,
url: `${incidentUrl}/${incidentId}`,
logger,
data: { ...(incident as Record<string, unknown>) },
proxySettings,
});
return {

View file

@ -4,25 +4,40 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Logger } from '../../../../../src/core/server';
import { Services, ActionTypeExecutorResult } from '../types';
import { validateParams, validateSecrets } from '../lib';
import { getActionType, SlackActionType, SlackActionTypeExecutorOptions } from './slack';
import { actionsConfigMock } from '../actions_config.mock';
import { actionsMock } from '../mocks';
import { createActionTypeRegistry } from './index.test';
jest.mock('@slack/webhook', () => {
return {
IncomingWebhook: jest.fn().mockImplementation(() => {
return { send: (message: string) => {} };
}),
};
});
const ACTION_TYPE_ID = '.slack';
const services: Services = actionsMock.createServices();
let actionType: SlackActionType;
let mockedLogger: jest.Mocked<Logger>;
beforeAll(() => {
const { logger } = createActionTypeRegistry();
actionType = getActionType({
async executor(options) {
return { status: 'ok', actionId: options.actionId };
},
configurationUtilities: actionsConfigMock.create(),
logger,
});
mockedLogger = logger;
expect(actionType).toBeTruthy();
});
describe('action registeration', () => {
@ -83,6 +98,7 @@ describe('validateActionTypeSecrets()', () => {
test('should validate and pass when the slack webhookUrl is whitelisted', () => {
actionType = getActionType({
logger: mockedLogger,
configurationUtilities: {
...actionsConfigMock.create(),
ensureWhitelistedUri: (url) => {
@ -98,9 +114,10 @@ describe('validateActionTypeSecrets()', () => {
test('config validation returns an error if the specified URL isnt whitelisted', () => {
actionType = getActionType({
logger: mockedLogger,
configurationUtilities: {
...actionsConfigMock.create(),
ensureWhitelistedHostname: (url) => {
ensureWhitelistedHostname: () => {
throw new Error(`target hostname is not whitelisted`);
},
},
@ -136,6 +153,7 @@ describe('execute()', () => {
actionType = getActionType({
executor: mockSlackExecutor,
logger: mockedLogger,
configurationUtilities: actionsConfigMock.create(),
});
});
@ -147,6 +165,10 @@ describe('execute()', () => {
config: {},
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
proxySettings: {
proxyUrl: 'https://someproxyhost',
rejectUnauthorizedCertificates: false,
},
});
expect(response).toMatchInlineSnapshot(`
Object {
@ -170,4 +192,25 @@ describe('execute()', () => {
`"slack mockExecutor failure: this invocation should fail"`
);
});
test('calls the mock executor with success proxy', async () => {
const actionTypeProxy = getActionType({
logger: mockedLogger,
configurationUtilities: actionsConfigMock.create(),
});
await actionTypeProxy.executor({
actionId: 'some-id',
services,
config: {},
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
proxySettings: {
proxyUrl: 'https://someproxyhost',
rejectUnauthorizedCertificates: false,
},
});
expect(mockedLogger.info).toHaveBeenCalledWith(
'IncomingWebhook was called with proxyUrl https://someproxyhost'
);
});
});

View file

@ -6,11 +6,14 @@
import { URL } from 'url';
import { curry } from 'lodash';
import { HttpsProxyAgent } from 'https-proxy-agent';
import HttpProxyAgent from 'http-proxy-agent';
import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
import { IncomingWebhook, IncomingWebhookResult } from '@slack/webhook';
import { pipe } from 'fp-ts/lib/pipeable';
import { map, getOrElse } from 'fp-ts/lib/Option';
import { Logger } from '../../../../../src/core/server';
import { getRetryAfterIntervalFromHeaders } from './lib/http_rersponse_retry_header';
import {
@ -20,6 +23,7 @@ import {
ExecutorType,
} from '../types';
import { ActionsConfigurationUtilities } from '../actions_config';
import { getProxyAgent } from './lib/get_proxy_agent';
export type SlackActionType = ActionType<{}, ActionTypeSecretsType, ActionParamsType, unknown>;
export type SlackActionTypeExecutorOptions = ActionTypeExecutorOptions<
@ -49,9 +53,11 @@ const ParamsSchema = schema.object({
// customizing executor is only used for tests
export function getActionType({
logger,
configurationUtilities,
executor = slackExecutor,
executor = curry(slackExecutor)({ logger }),
}: {
logger: Logger;
configurationUtilities: ActionsConfigurationUtilities;
executor?: ExecutorType<{}, ActionTypeSecretsType, ActionParamsType, unknown>;
}): SlackActionType {
@ -99,6 +105,7 @@ function valdiateActionTypeConfig(
// action executor
async function slackExecutor(
{ logger }: { logger: Logger },
execOptions: SlackActionTypeExecutorOptions
): Promise<ActionTypeExecutorResult<unknown>> {
const actionId = execOptions.actionId;
@ -109,10 +116,22 @@ async function slackExecutor(
const { webhookUrl } = secrets;
const { message } = params;
let proxyAgent: HttpsProxyAgent | HttpProxyAgent | undefined;
if (execOptions.proxySettings) {
proxyAgent = getProxyAgent(execOptions.proxySettings, logger);
logger.info(`IncomingWebhook was called with proxyUrl ${execOptions.proxySettings.proxyUrl}`);
}
try {
const webhook = new IncomingWebhook(webhookUrl);
// https://slack.dev/node-slack-sdk/webhook
// node-slack-sdk use Axios inside :)
const webhook = new IncomingWebhook(webhookUrl, {
agent: proxyAgent,
});
result = await webhook.send(message);
} catch (err) {
logger.error(`error on ${actionId} slack event: ${err.message}`);
if (err.original == null || err.original.response == null) {
return serviceErrorResult(actionId, err.message);
}
@ -143,6 +162,8 @@ async function slackExecutor(
},
}
);
logger.error(`error on ${actionId} slack action: ${errMessage}`);
return errorResult(actionId, errMessage);
}

View file

@ -4,10 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('axios', () => ({
request: jest.fn(),
}));
import { Services } from '../types';
import { validateConfig, validateSecrets, validateParams } from '../lib';
import { actionsConfigMock } from '../actions_config.mock';
@ -24,7 +20,22 @@ import {
WebhookMethods,
} from './webhook';
const axiosRequestMock = axios.request as jest.Mock;
import * as utils from './lib/axios_utils';
jest.mock('axios');
jest.mock('./lib/axios_utils', () => {
const originalUtils = jest.requireActual('./lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
patch: jest.fn(),
};
});
axios.create = jest.fn(() => axios);
const requestMock = utils.request as jest.Mock;
axios.create = jest.fn(() => axios);
const ACTION_TYPE_ID = '.webhook';
@ -227,7 +238,7 @@ describe('params validation', () => {
describe('execute()', () => {
beforeAll(() => {
axiosRequestMock.mockReset();
requestMock.mockReset();
actionType = getActionType({
logger: mockedLogger,
configurationUtilities: actionsConfigMock.create(),
@ -235,8 +246,8 @@ describe('execute()', () => {
});
beforeEach(() => {
axiosRequestMock.mockReset();
axiosRequestMock.mockResolvedValue({
requestMock.mockReset();
requestMock.mockResolvedValue({
status: 200,
statusText: '',
data: '',
@ -261,17 +272,42 @@ describe('execute()', () => {
params: { body: 'some data' },
});
expect(axiosRequestMock.mock.calls[0][0]).toMatchInlineSnapshot(`
expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"auth": Object {
"password": "123",
"username": "abc",
},
"axios": undefined,
"data": "some data",
"headers": Object {
"aheader": "a value",
},
"logger": Object {
"context": Array [],
"debug": [MockFunction] {
"calls": Array [
Array [
"response from webhook action \\"some-id\\": [HTTP 200] ",
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
"error": [MockFunction],
"fatal": [MockFunction],
"get": [MockFunction],
"info": [MockFunction],
"log": [MockFunction],
"trace": [MockFunction],
"warn": [MockFunction],
},
"method": "post",
"proxySettings": undefined,
"url": "https://abc.def/my-webhook",
}
`);
@ -294,13 +330,38 @@ describe('execute()', () => {
params: { body: 'some data' },
});
expect(axiosRequestMock.mock.calls[0][0]).toMatchInlineSnapshot(`
expect(requestMock.mock.calls[0][0]).toMatchInlineSnapshot(`
Object {
"axios": undefined,
"data": "some data",
"headers": Object {
"aheader": "a value",
},
"logger": Object {
"context": Array [],
"debug": [MockFunction] {
"calls": Array [
Array [
"response from webhook action \\"some-id\\": [HTTP 200] ",
],
],
"results": Array [
Object {
"type": "return",
"value": undefined,
},
],
},
"error": [MockFunction],
"fatal": [MockFunction],
"get": [MockFunction],
"info": [MockFunction],
"log": [MockFunction],
"trace": [MockFunction],
"warn": [MockFunction],
},
"method": "post",
"proxySettings": undefined,
"url": "https://abc.def/my-webhook",
}
`);

View file

@ -15,6 +15,7 @@ import { isOk, promiseResult, Result } from './lib/result_type';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
import { ActionsConfigurationUtilities } from '../actions_config';
import { Logger } from '../../../../../src/core/server';
import { request } from './lib/axios_utils';
// config definition
export enum WebhookMethods {
@ -136,13 +137,18 @@ export async function executor(
? { auth: { username: secrets.user, password: secrets.password } }
: {};
const axiosInstance = axios.create();
const result: Result<AxiosResponse, AxiosError> = await promiseResult(
axios.request({
request({
axios: axiosInstance,
method,
url,
logger,
...basicAuth,
headers,
data,
proxySettings: execOptions.proxySettings,
})
);
@ -159,7 +165,7 @@ export async function executor(
if (error.response) {
const { status, statusText, headers: responseHeaders } = error.response;
const message = `[${status}] ${statusText}`;
logger.warn(`error on ${actionId} webhook event: ${message}`);
logger.error(`error on ${actionId} webhook event: ${message}`);
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
// special handling for 5xx
@ -178,7 +184,7 @@ export async function executor(
return errorResultInvalid(actionId, message);
}
logger.warn(`error on ${actionId} webhook action: unexpected error`);
logger.error(`error on ${actionId} webhook action: unexpected error`);
return errorResultUnexpectedError(actionId);
}
}

View file

@ -15,6 +15,7 @@ describe('config validation', () => {
"*",
],
"preconfigured": Object {},
"rejectUnauthorizedCertificates": true,
"whitelistedHosts": Array [
"*",
],
@ -33,6 +34,7 @@ describe('config validation', () => {
},
},
},
rejectUnauthorizedCertificates: false,
};
expect(configSchema.validate(config)).toMatchInlineSnapshot(`
Object {
@ -50,6 +52,7 @@ describe('config validation', () => {
"secrets": Object {},
},
},
"rejectUnauthorizedCertificates": false,
"whitelistedHosts": Array [
"*",
],

View file

@ -32,6 +32,9 @@ export const configSchema = schema.object({
defaultValue: {},
validate: validatePreconfigured,
}),
proxyUrl: schema.maybe(schema.string()),
proxyHeaders: schema.maybe(schema.recordOf(schema.string(), schema.string())),
rejectUnauthorizedCertificates: schema.boolean({ defaultValue: true }),
});
export type ActionsConfig = TypeOf<typeof configSchema>;

View file

@ -12,6 +12,7 @@ import {
GetServicesFunction,
RawAction,
PreConfiguredAction,
ProxySettings,
} from '../types';
import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server';
import { SpacesServiceSetup } from '../../../spaces/server';
@ -28,6 +29,7 @@ export interface ActionExecutorContext {
actionTypeRegistry: ActionTypeRegistryContract;
eventLogger: IEventLogger;
preconfiguredActions: PreConfiguredAction[];
proxySettings?: ProxySettings;
}
export interface ExecuteOptions {
@ -78,6 +80,7 @@ export class ActionExecutor {
eventLogger,
preconfiguredActions,
getActionsClientWithRequest,
proxySettings,
} = this.actionExecutorContext!;
const services = getServices(request);
@ -133,6 +136,7 @@ export class ActionExecutor {
params: validatedParams,
config: validatedConfig,
secrets: validatedSecrets,
proxySettings,
});
} catch (err) {
rawResult = {

View file

@ -34,6 +34,7 @@ describe('Actions Plugin', () => {
enabledActionTypes: ['*'],
whitelistedHosts: ['*'],
preconfigured: {},
rejectUnauthorizedCertificates: true,
});
plugin = new ActionsPlugin(context);
coreSetup = coreMock.createSetup();
@ -194,6 +195,7 @@ describe('Actions Plugin', () => {
secrets: {},
},
},
rejectUnauthorizedCertificates: true,
});
plugin = new ActionsPlugin(context);
coreSetup = coreMock.createSetup();
@ -217,7 +219,7 @@ describe('Actions Plugin', () => {
// coreMock.createSetup doesn't support Plugin generics
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await plugin.setup(coreSetup as any, pluginsSetup);
const pluginStart = plugin.start(coreStart, pluginsStart);
const pluginStart = await plugin.start(coreStart, pluginsStart);
expect(pluginStart.isActionExecutable('preconfiguredServerLog', '.server-log')).toBe(true);
});
@ -232,7 +234,7 @@ describe('Actions Plugin', () => {
usingEphemeralEncryptionKey: false,
},
});
const pluginStart = plugin.start(coreStart, pluginsStart);
const pluginStart = await plugin.start(coreStart, pluginsStart);
await pluginStart.getActionsClientWithRequest(httpServerMock.createKibanaRequest());
});
@ -241,7 +243,7 @@ describe('Actions Plugin', () => {
// coreMock.createSetup doesn't support Plugin generics
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await plugin.setup(coreSetup as any, pluginsSetup);
const pluginStart = plugin.start(coreStart, pluginsStart);
const pluginStart = await plugin.start(coreStart, pluginsStart);
expect(pluginsSetup.encryptedSavedObjects.usingEphemeralEncryptionKey).toEqual(true);
await expect(

View file

@ -116,6 +116,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
private readonly config: Promise<ActionsConfig>;
private readonly logger: Logger;
private actionsConfig?: ActionsConfig;
private serverBasePath?: string;
private taskRunnerFactory?: TaskRunnerFactory;
private actionTypeRegistry?: ActionTypeRegistry;
@ -173,12 +174,12 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
// get executions count
const taskRunnerFactory = new TaskRunnerFactory(actionExecutor);
const actionsConfig = (await this.config) as ActionsConfig;
const actionsConfigUtils = getActionsConfigurationUtilities(actionsConfig);
this.actionsConfig = (await this.config) as ActionsConfig;
const actionsConfigUtils = getActionsConfigurationUtilities(this.actionsConfig);
for (const preconfiguredId of Object.keys(actionsConfig.preconfigured)) {
for (const preconfiguredId of Object.keys(this.actionsConfig.preconfigured)) {
this.preconfiguredActions.push({
...actionsConfig.preconfigured[preconfiguredId],
...this.actionsConfig.preconfigured[preconfiguredId],
id: preconfiguredId,
isPreconfigured: true,
});
@ -317,6 +318,14 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
encryptedSavedObjectsClient,
actionTypeRegistry: actionTypeRegistry!,
preconfiguredActions,
proxySettings:
this.actionsConfig && this.actionsConfig.proxyUrl
? {
proxyUrl: this.actionsConfig.proxyUrl,
proxyHeaders: this.actionsConfig.proxyHeaders,
rejectUnauthorizedCertificates: this.actionsConfig.rejectUnauthorizedCertificates,
}
: undefined,
});
taskRunnerFactory!.initialize({

View file

@ -58,6 +58,7 @@ export interface ActionTypeExecutorOptions<Config, Secrets, Params> {
config: Config;
secrets: Secrets;
params: Params;
proxySettings?: ProxySettings;
}
export interface ActionResult<Config extends ActionTypeConfig = ActionTypeConfig> {
@ -140,3 +141,9 @@ export interface ActionTaskExecutorParams {
spaceId: string;
actionTaskParamsId: string;
}
export interface ProxySettings {
proxyUrl: string;
proxyHeaders?: Record<string, string>;
rejectUnauthorizedCertificates: boolean;
}

View file

@ -11,4 +11,5 @@ export default createTestConfig('basic', {
disabledPlugins: [],
license: 'basic',
ssl: true,
enableActionsProxy: false,
});

View file

@ -5,6 +5,7 @@
*/
import path from 'path';
import getPort from 'get-port';
import fs from 'fs';
import { CA_CERT_PATH } from '@kbn/dev-utils';
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
@ -15,6 +16,7 @@ interface CreateTestConfigOptions {
license: string;
disabledPlugins?: string[];
ssl?: boolean;
enableActionsProxy: boolean;
}
// test.not-enabled is specifically not enabled
@ -56,6 +58,10 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
fs.statSync(path.resolve(__dirname, 'fixtures', 'plugins', file)).isDirectory()
);
const actionsProxyUrl = options.enableActionsProxy
? [`--xpack.actions.proxyUrl=http://localhost:${await getPort()}`]
: [];
return {
testFiles: [require.resolve(`../${name}/tests/`)],
servers,
@ -85,6 +91,9 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
])}`,
'--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"',
`--xpack.actions.enabledActionTypes=${JSON.stringify(enabledActionTypes)}`,
...actionsProxyUrl,
'--xpack.actions.rejectUnauthorizedCertificates=false',
'--xpack.eventLog.logEntries=true',
`--xpack.actions.preconfigured=${JSON.stringify({
'my-slack1': {

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import httpProxy from 'http-proxy';
export const getHttpProxyServer = (
targetUrl: string,
onProxyResHandler: (proxyRes?: unknown, req?: unknown, res?: unknown) => void
): httpProxy => {
const proxyServer = httpProxy.createProxyServer({
target: targetUrl,
secure: false,
selfHandleResponse: false,
});
proxyServer.on('proxyRes', (proxyRes: unknown, req: unknown, res: unknown) => {
onProxyResHandler(proxyRes, req, res);
});
return proxyServer;
};
export const getProxyUrl = (kbnTestServerConfig: any) => {
const proxyUrl = kbnTestServerConfig
.find((val: string) => val.startsWith('--xpack.actions.proxyUrl='))
.replace('--xpack.actions.proxyUrl=', '');
return new URL(proxyUrl);
};

View file

@ -11,4 +11,5 @@ export default createTestConfig('security_and_spaces', {
disabledPlugins: [],
license: 'trial',
ssl: true,
enableActionsProxy: true,
});

View file

@ -6,6 +6,7 @@
import expect from '@kbn/expect';
import { getHttpProxyServer, getProxyUrl } from '../../../../common/lib/get_proxy_server';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
@ -35,6 +36,7 @@ const mapping = [
export default function jiraTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const config = getService('config');
const mockJira = {
config: {
@ -73,12 +75,19 @@ export default function jiraTest({ getService }: FtrProviderContext) {
};
let jiraSimulatorURL: string = '<could not determine kibana url>';
let proxyServer: any;
let proxyHaveBeenCalled = false;
describe('Jira', () => {
before(() => {
jiraSimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.JIRA)
);
proxyServer = getHttpProxyServer(kibanaServer.resolveUrl('/'), () => {
proxyHaveBeenCalled = true;
});
const proxyUrl = getProxyUrl(config.get('kbnTestServer.serverArgs'));
proxyServer.listen(Number(proxyUrl.port));
});
describe('Jira - Action Creation', () => {
@ -529,6 +538,8 @@ export default function jiraTest({ getService }: FtrProviderContext) {
})
.expect(200);
expect(proxyHaveBeenCalled).to.equal(true);
expect(body).to.eql({
status: 'ok',
actionId: simulatedActionId,
@ -542,5 +553,9 @@ export default function jiraTest({ getService }: FtrProviderContext) {
});
});
});
after(() => {
proxyServer.close();
});
});
}

View file

@ -6,6 +6,7 @@
import expect from '@kbn/expect';
import { getHttpProxyServer, getProxyUrl } from '../../../../common/lib/get_proxy_server';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
@ -17,16 +18,25 @@ import {
export default function pagerdutyTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const config = getService('config');
describe('pagerduty action', () => {
let simulatedActionId = '';
let pagerdutySimulatorURL: string = '<could not determine kibana url>';
let proxyServer: any;
let proxyHaveBeenCalled = false;
// need to wait for kibanaServer to settle ...
before(() => {
pagerdutySimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.PAGERDUTY)
);
proxyServer = getHttpProxyServer(kibanaServer.resolveUrl('/'), () => {
proxyHaveBeenCalled = true;
});
const proxyUrl = getProxyUrl(config.get('kbnTestServer.serverArgs'));
proxyServer.listen(Number(proxyUrl.port));
});
it('should return successfully when passed valid create parameters', async () => {
@ -144,6 +154,8 @@ export default function pagerdutyTest({ getService }: FtrProviderContext) {
},
})
.expect(200);
expect(proxyHaveBeenCalled).to.equal(true);
expect(result).to.eql({
status: 'ok',
actionId: simulatedActionId,
@ -202,5 +214,9 @@ export default function pagerdutyTest({ getService }: FtrProviderContext) {
expect(result.message).to.match(/error posting pagerduty event: http status 502/);
expect(result.retry).to.equal(true);
});
after(() => {
proxyServer.close();
});
});
}

View file

@ -6,6 +6,7 @@
import expect from '@kbn/expect';
import { getHttpProxyServer, getProxyUrl } from '../../../../common/lib/get_proxy_server';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
@ -35,6 +36,7 @@ const mapping = [
export default function resilientTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const config = getService('config');
const mockResilient = {
config: {
@ -73,12 +75,19 @@ export default function resilientTest({ getService }: FtrProviderContext) {
};
let resilientSimulatorURL: string = '<could not determine kibana url>';
let proxyServer: any;
let proxyHaveBeenCalled = false;
describe('IBM Resilient', () => {
before(() => {
resilientSimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.RESILIENT)
);
proxyServer = getHttpProxyServer(kibanaServer.resolveUrl('/'), () => {
proxyHaveBeenCalled = true;
});
const proxyUrl = getProxyUrl(config.get('kbnTestServer.serverArgs'));
proxyServer.listen(Number(proxyUrl.port));
});
describe('IBM Resilient - Action Creation', () => {
@ -529,6 +538,8 @@ export default function resilientTest({ getService }: FtrProviderContext) {
})
.expect(200);
expect(proxyHaveBeenCalled).to.equal(true);
expect(body).to.eql({
status: 'ok',
actionId: simulatedActionId,
@ -542,5 +553,9 @@ export default function resilientTest({ getService }: FtrProviderContext) {
});
});
});
after(() => {
proxyServer.close();
});
});
}

View file

@ -6,6 +6,7 @@
import expect from '@kbn/expect';
import { getHttpProxyServer, getProxyUrl } from '../../../../common/lib/get_proxy_server';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
@ -35,6 +36,7 @@ const mapping = [
export default function servicenowTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const config = getService('config');
const mockServiceNow = {
config: {
@ -72,12 +74,20 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
};
let servicenowSimulatorURL: string = '<could not determine kibana url>';
let proxyServer: any;
let proxyHaveBeenCalled = false;
describe('ServiceNow', () => {
before(() => {
servicenowSimulatorURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW)
);
proxyServer = getHttpProxyServer(kibanaServer.resolveUrl('/'), () => {
proxyHaveBeenCalled = true;
});
const proxyUrl = getProxyUrl(config.get('kbnTestServer.serverArgs'));
proxyServer.listen(Number(proxyUrl.port));
});
describe('ServiceNow - Action Creation', () => {
@ -448,6 +458,7 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
},
})
.expect(200);
expect(proxyHaveBeenCalled).to.equal(true);
expect(result).to.eql({
status: 'ok',
@ -462,5 +473,9 @@ export default function servicenowTest({ getService }: FtrProviderContext) {
});
});
});
after(() => {
proxyServer.close();
});
});
}

View file

@ -7,6 +7,7 @@
import expect from '@kbn/expect';
import http from 'http';
import getPort from 'get-port';
import { getHttpProxyServer, getProxyUrl } from '../../../../common/lib/get_proxy_server';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { getSlackServer } from '../../../../common/fixtures/plugins/actions_simulators/server/plugin';
@ -14,18 +15,27 @@ import { getSlackServer } from '../../../../common/fixtures/plugins/actions_simu
// eslint-disable-next-line import/no-default-export
export default function slackTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const config = getService('config');
describe('slack action', () => {
let simulatedActionId = '';
let slackSimulatorURL: string = '';
let slackServer: http.Server;
let proxyServer: any;
let proxyHaveBeenCalled = false;
// need to wait for kibanaServer to settle ...
before(async () => {
slackServer = await getSlackServer();
const availablePort = await getPort({ port: 9000 });
slackServer.listen(availablePort);
slackSimulatorURL = `http://localhost:${availablePort}`;
proxyServer = getHttpProxyServer(slackSimulatorURL, () => {
proxyHaveBeenCalled = true;
});
const proxyUrl = getProxyUrl(config.get('kbnTestServer.serverArgs'));
proxyServer.listen(Number(proxyUrl.port));
});
it('should return 200 when creating a slack action successfully', async () => {
@ -155,6 +165,7 @@ export default function slackTest({ getService }: FtrProviderContext) {
})
.expect(200);
expect(result.status).to.eql('ok');
expect(proxyHaveBeenCalled).to.equal(true);
});
it('should handle an empty message error', async () => {
@ -222,6 +233,7 @@ export default function slackTest({ getService }: FtrProviderContext) {
after(() => {
slackServer.close();
proxyServer.close();
});
});
}

View file

@ -8,6 +8,7 @@ import http from 'http';
import getPort from 'get-port';
import expect from '@kbn/expect';
import { URL, format as formatUrl } from 'url';
import { getHttpProxyServer, getProxyUrl } from '../../../../common/lib/get_proxy_server';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import {
getExternalServiceSimulatorPath,
@ -31,6 +32,7 @@ function parsePort(url: Record<string, string>): Record<string, string | null |
export default function webhookTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
const configService = getService('config');
async function createWebhookAction(
webhookSimulatorURL: string,
@ -69,6 +71,8 @@ export default function webhookTest({ getService }: FtrProviderContext) {
let webhookSimulatorURL: string = '';
let webhookServer: http.Server;
let kibanaURL: string = '<could not determine kibana url>';
let proxyServer: any;
let proxyHaveBeenCalled = false;
// need to wait for kibanaServer to settle ...
before(async () => {
webhookServer = await getWebhookServer();
@ -76,6 +80,12 @@ export default function webhookTest({ getService }: FtrProviderContext) {
webhookServer.listen(availablePort);
webhookSimulatorURL = `http://localhost:${availablePort}`;
proxyServer = getHttpProxyServer(webhookSimulatorURL, () => {
proxyHaveBeenCalled = true;
});
const proxyUrl = getProxyUrl(configService.get('kbnTestServer.serverArgs'));
proxyServer.listen(Number(proxyUrl.port));
kibanaURL = kibanaServer.resolveUrl(
getExternalServiceSimulatorPath(ExternalServiceSimulator.WEBHOOK)
);
@ -140,6 +150,7 @@ export default function webhookTest({ getService }: FtrProviderContext) {
.expect(200);
expect(result.status).to.eql('ok');
expect(proxyHaveBeenCalled).to.equal(true);
});
it('should support the POST method against webhook target', async () => {
@ -218,7 +229,7 @@ export default function webhookTest({ getService }: FtrProviderContext) {
.expect(200);
expect(result.status).to.eql('error');
expect(result.message).to.match(/error calling webhook, unexpected error/);
expect(result.message).to.match(/error calling webhook, retry later/);
});
it('should handle failing webhook targets', async () => {
@ -240,6 +251,7 @@ export default function webhookTest({ getService }: FtrProviderContext) {
after(() => {
webhookServer.close();
proxyServer.close();
});
});
}

View file

@ -7,4 +7,8 @@
import { createTestConfig } from '../common/config';
// eslint-disable-next-line import/no-default-export
export default createTestConfig('spaces_only', { disabledPlugins: ['security'], license: 'trial' });
export default createTestConfig('spaces_only', {
disabledPlugins: ['security'],
license: 'trial',
enableActionsProxy: false,
});

View file

@ -3913,6 +3913,20 @@
resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz#9140779736aa2655635ee756e2467d787cfe8a2a"
integrity sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==
"@types/http-proxy-agent@^2.0.2":
version "2.0.2"
resolved "https://registry.yarnpkg.com/@types/http-proxy-agent/-/http-proxy-agent-2.0.2.tgz#942c1f35c7e1f0edd1b6ffae5d0f9051cfb32be1"
integrity sha512-2S6IuBRhqUnH1/AUx9k8KWtY3Esg4eqri946MnxTG5HwehF1S5mqLln8fcyMiuQkY72p2gH3W+rIPqp5li0LyQ==
dependencies:
"@types/node" "*"
"@types/http-proxy@^1.17.4":
version "1.17.4"
resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.4.tgz#e7c92e3dbe3e13aa799440ff42e6d3a17a9d045b"
integrity sha512-IrSHl2u6AWXduUaDLqYpt45tLVCtYv7o4Z0s1KghBCDgIIS9oW5K1H8mZG/A2CfeLdEa7rTd1ACOiHBc1EMT2Q==
dependencies:
"@types/node" "*"
"@types/inert@^5.1.2":
version "5.1.2"
resolved "https://registry.yarnpkg.com/@types/inert/-/inert-5.1.2.tgz#2bb8bef3b2462f904c960654c9edfa39285a85c6"