Allow action types to perform their own mustache variable escaping in parameter templates (#83919)
resolves https://github.com/elastic/kibana/issues/79371 resolves https://github.com/elastic/kibana/issues/62928 In this PR, we allow action types to determine how to escape the variables used in their parameters, when rendered as mustache templates. Prior to this, action parameters were recursively rendered as mustache templates using the default mustache templating, by the alerts library. The default mustache templating used html escaping. Action types opt-in to the new capability via a new optional method in the action type, `renderParameterTemplates()`. If not provided, the previous recursive rendering is done, but now with no escaping at all. For #62928, changed the mustache template rendering to be replaced with the error message, if an error occurred, so at least you can now see that an error occurred. Useful to diagnose problems with invalid mustache templates.
This commit is contained in:
parent
5b723ad206
commit
7873e3685b
|
@ -396,4 +396,37 @@ describe('execute()', () => {
|
|||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('renders parameter templates as expected', async () => {
|
||||
expect(actionType.renderParameterTemplates).toBeTruthy();
|
||||
const paramsWithTemplates = {
|
||||
to: [],
|
||||
cc: ['{{rogue}}'],
|
||||
bcc: ['jim', '{{rogue}}', 'bob'],
|
||||
subject: '{{rogue}}',
|
||||
message: '{{rogue}}',
|
||||
};
|
||||
const variables = {
|
||||
rogue: '*bold*',
|
||||
};
|
||||
const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables);
|
||||
// Yes, this is tested in the snapshot below, but it's double-escaped there,
|
||||
// so easier to see here that the escaping is correct.
|
||||
expect(params.message).toBe('\\*bold\\*');
|
||||
expect(params).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"bcc": Array [
|
||||
"jim",
|
||||
"*bold*",
|
||||
"bob",
|
||||
],
|
||||
"cc": Array [
|
||||
"*bold*",
|
||||
],
|
||||
"message": "\\\\*bold\\\\*",
|
||||
"subject": "*bold*",
|
||||
"to": Array [],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,6 +14,7 @@ import { portSchema } from './lib/schemas';
|
|||
import { Logger } from '../../../../../src/core/server';
|
||||
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
|
||||
import { ActionsConfigurationUtilities } from '../actions_config';
|
||||
import { renderMustacheString, renderMustacheObject } from '../lib/mustache_renderer';
|
||||
|
||||
export type EmailActionType = ActionType<
|
||||
ActionTypeConfigType,
|
||||
|
@ -140,10 +141,23 @@ export function getActionType(params: GetActionTypeParams): EmailActionType {
|
|||
secrets: SecretsSchema,
|
||||
params: ParamsSchema,
|
||||
},
|
||||
renderParameterTemplates,
|
||||
executor: curry(executor)({ logger }),
|
||||
};
|
||||
}
|
||||
|
||||
function renderParameterTemplates(
|
||||
params: ActionParamsType,
|
||||
variables: Record<string, unknown>
|
||||
): ActionParamsType {
|
||||
return {
|
||||
// most of the params need no escaping
|
||||
...renderMustacheObject(params, variables),
|
||||
// message however, needs to escaped as markdown
|
||||
message: renderMustacheString(params.message, variables, 'markdown'),
|
||||
};
|
||||
}
|
||||
|
||||
// action executor
|
||||
|
||||
async function executor(
|
||||
|
|
|
@ -213,4 +213,16 @@ describe('execute()', () => {
|
|||
'IncomingWebhook was called with proxyUrl https://someproxyhost'
|
||||
);
|
||||
});
|
||||
|
||||
test('renders parameter templates as expected', async () => {
|
||||
expect(actionType.renderParameterTemplates).toBeTruthy();
|
||||
const paramsWithTemplates = {
|
||||
message: '{{rogue}}',
|
||||
};
|
||||
const variables = {
|
||||
rogue: '*bold*',
|
||||
};
|
||||
const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables);
|
||||
expect(params.message).toBe('`*bold*`');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -15,6 +15,7 @@ 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 { renderMustacheString } from '../lib/mustache_renderer';
|
||||
|
||||
import {
|
||||
ActionType,
|
||||
|
@ -73,10 +74,20 @@ export function getActionType({
|
|||
}),
|
||||
params: ParamsSchema,
|
||||
},
|
||||
renderParameterTemplates,
|
||||
executor,
|
||||
};
|
||||
}
|
||||
|
||||
function renderParameterTemplates(
|
||||
params: ActionParamsType,
|
||||
variables: Record<string, unknown>
|
||||
): ActionParamsType {
|
||||
return {
|
||||
message: renderMustacheString(params.message, variables, 'slack'),
|
||||
};
|
||||
}
|
||||
|
||||
function valdiateActionTypeConfig(
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
secretsObject: ActionTypeSecretsType
|
||||
|
|
|
@ -373,4 +373,28 @@ describe('execute()', () => {
|
|||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('renders parameter templates as expected', async () => {
|
||||
const rogue = `double-quote:"; line-break->\n`;
|
||||
|
||||
expect(actionType.renderParameterTemplates).toBeTruthy();
|
||||
const paramsWithTemplates = {
|
||||
body: '{"x": "{{rogue}}"}',
|
||||
};
|
||||
const variables = {
|
||||
rogue,
|
||||
};
|
||||
const params = actionType.renderParameterTemplates!(paramsWithTemplates, variables);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let paramsObject: any;
|
||||
try {
|
||||
paramsObject = JSON.parse(`${params.body}`);
|
||||
} catch (err) {
|
||||
expect(err).toBe(null); // kinda weird, but test should fail if it can't parse
|
||||
}
|
||||
|
||||
expect(paramsObject.x).toBe(rogue);
|
||||
expect(params.body).toBe(`{"x": "double-quote:\\"; line-break->\\n"}`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,6 +16,7 @@ import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from
|
|||
import { ActionsConfigurationUtilities } from '../actions_config';
|
||||
import { Logger } from '../../../../../src/core/server';
|
||||
import { request } from './lib/axios_utils';
|
||||
import { renderMustacheString } from '../lib/mustache_renderer';
|
||||
|
||||
// config definition
|
||||
export enum WebhookMethods {
|
||||
|
@ -91,10 +92,21 @@ export function getActionType({
|
|||
secrets: SecretsSchema,
|
||||
params: ParamsSchema,
|
||||
},
|
||||
renderParameterTemplates,
|
||||
executor: curry(executor)({ logger }),
|
||||
};
|
||||
}
|
||||
|
||||
function renderParameterTemplates(
|
||||
params: ActionParamsType,
|
||||
variables: Record<string, unknown>
|
||||
): ActionParamsType {
|
||||
if (!params.body) return params;
|
||||
return {
|
||||
body: renderMustacheString(params.body, variables, 'json'),
|
||||
};
|
||||
}
|
||||
|
||||
function validateActionTypeConfig(
|
||||
configurationUtilities: ActionsConfigurationUtilities,
|
||||
configObject: ActionTypeConfigType
|
||||
|
|
183
x-pack/plugins/actions/server/lib/mustache_renderer.test.ts
Normal file
183
x-pack/plugins/actions/server/lib/mustache_renderer.test.ts
Normal file
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
* 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 { renderMustacheString, renderMustacheObject, Escape } from './mustache_renderer';
|
||||
|
||||
const variables = {
|
||||
a: 1,
|
||||
b: '2',
|
||||
c: false,
|
||||
d: null,
|
||||
e: undefined,
|
||||
f: {
|
||||
g: 3,
|
||||
h: null,
|
||||
},
|
||||
i: [42, 43, 44],
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
amp: '&',
|
||||
nl: '\n',
|
||||
dq: '"',
|
||||
bt: '`',
|
||||
bs: '\\',
|
||||
st: '*',
|
||||
ul: '_',
|
||||
st_lt: '*<',
|
||||
};
|
||||
|
||||
describe('mustache_renderer', () => {
|
||||
describe('renderMustacheString()', () => {
|
||||
for (const escapeVal of ['none', 'slack', 'markdown', 'json']) {
|
||||
const escape = escapeVal as Escape;
|
||||
|
||||
it(`handles basic templating that does not need escaping for ${escape}`, () => {
|
||||
expect(renderMustacheString('', variables, escape)).toBe('');
|
||||
expect(renderMustacheString('{{a}}', variables, escape)).toBe('1');
|
||||
expect(renderMustacheString('{{b}}', variables, escape)).toBe('2');
|
||||
expect(renderMustacheString('{{c}}', variables, escape)).toBe('false');
|
||||
expect(renderMustacheString('{{d}}', variables, escape)).toBe('');
|
||||
expect(renderMustacheString('{{e}}', variables, escape)).toBe('');
|
||||
if (escape === 'markdown') {
|
||||
expect(renderMustacheString('{{f}}', variables, escape)).toBe('\\[object Object\\]');
|
||||
} else {
|
||||
expect(renderMustacheString('{{f}}', variables, escape)).toBe('[object Object]');
|
||||
}
|
||||
expect(renderMustacheString('{{f.g}}', variables, escape)).toBe('3');
|
||||
expect(renderMustacheString('{{f.h}}', variables, escape)).toBe('');
|
||||
expect(renderMustacheString('{{i}}', variables, escape)).toBe('42,43,44');
|
||||
});
|
||||
}
|
||||
|
||||
it('handles escape:none with commonly escaped strings', () => {
|
||||
expect(renderMustacheString('{{lt}}', variables, 'none')).toBe(variables.lt);
|
||||
expect(renderMustacheString('{{gt}}', variables, 'none')).toBe(variables.gt);
|
||||
expect(renderMustacheString('{{amp}}', variables, 'none')).toBe(variables.amp);
|
||||
expect(renderMustacheString('{{nl}}', variables, 'none')).toBe(variables.nl);
|
||||
expect(renderMustacheString('{{dq}}', variables, 'none')).toBe(variables.dq);
|
||||
expect(renderMustacheString('{{bt}}', variables, 'none')).toBe(variables.bt);
|
||||
expect(renderMustacheString('{{bs}}', variables, 'none')).toBe(variables.bs);
|
||||
expect(renderMustacheString('{{st}}', variables, 'none')).toBe(variables.st);
|
||||
expect(renderMustacheString('{{ul}}', variables, 'none')).toBe(variables.ul);
|
||||
});
|
||||
|
||||
it('handles escape:markdown with commonly escaped strings', () => {
|
||||
expect(renderMustacheString('{{lt}}', variables, 'markdown')).toBe(variables.lt);
|
||||
expect(renderMustacheString('{{gt}}', variables, 'markdown')).toBe(variables.gt);
|
||||
expect(renderMustacheString('{{amp}}', variables, 'markdown')).toBe(variables.amp);
|
||||
expect(renderMustacheString('{{nl}}', variables, 'markdown')).toBe(variables.nl);
|
||||
expect(renderMustacheString('{{dq}}', variables, 'markdown')).toBe(variables.dq);
|
||||
expect(renderMustacheString('{{bt}}', variables, 'markdown')).toBe('\\' + variables.bt);
|
||||
expect(renderMustacheString('{{bs}}', variables, 'markdown')).toBe('\\' + variables.bs);
|
||||
expect(renderMustacheString('{{st}}', variables, 'markdown')).toBe('\\' + variables.st);
|
||||
expect(renderMustacheString('{{ul}}', variables, 'markdown')).toBe('\\' + variables.ul);
|
||||
});
|
||||
|
||||
it('handles triple escapes', () => {
|
||||
expect(renderMustacheString('{{{bt}}}', variables, 'markdown')).toBe(variables.bt);
|
||||
expect(renderMustacheString('{{{bs}}}', variables, 'markdown')).toBe(variables.bs);
|
||||
expect(renderMustacheString('{{{st}}}', variables, 'markdown')).toBe(variables.st);
|
||||
expect(renderMustacheString('{{{ul}}}', variables, 'markdown')).toBe(variables.ul);
|
||||
});
|
||||
|
||||
it('handles escape:slack with commonly escaped strings', () => {
|
||||
expect(renderMustacheString('{{lt}}', variables, 'slack')).toBe('<');
|
||||
expect(renderMustacheString('{{gt}}', variables, 'slack')).toBe('>');
|
||||
expect(renderMustacheString('{{amp}}', variables, 'slack')).toBe('&');
|
||||
expect(renderMustacheString('{{nl}}', variables, 'slack')).toBe(variables.nl);
|
||||
expect(renderMustacheString('{{dq}}', variables, 'slack')).toBe(variables.dq);
|
||||
expect(renderMustacheString('{{bt}}', variables, 'slack')).toBe(`'`);
|
||||
expect(renderMustacheString('{{bs}}', variables, 'slack')).toBe(variables.bs);
|
||||
expect(renderMustacheString('{{st}}', variables, 'slack')).toBe('`*`');
|
||||
expect(renderMustacheString('{{ul}}', variables, 'slack')).toBe('`_`');
|
||||
// html escapes not needed when using backtic escaping
|
||||
expect(renderMustacheString('{{st_lt}}', variables, 'slack')).toBe('`*<`');
|
||||
});
|
||||
|
||||
it('handles escape:json with commonly escaped strings', () => {
|
||||
expect(renderMustacheString('{{lt}}', variables, 'json')).toBe(variables.lt);
|
||||
expect(renderMustacheString('{{gt}}', variables, 'json')).toBe(variables.gt);
|
||||
expect(renderMustacheString('{{amp}}', variables, 'json')).toBe(variables.amp);
|
||||
expect(renderMustacheString('{{nl}}', variables, 'json')).toBe('\\n');
|
||||
expect(renderMustacheString('{{dq}}', variables, 'json')).toBe('\\"');
|
||||
expect(renderMustacheString('{{bt}}', variables, 'json')).toBe(variables.bt);
|
||||
expect(renderMustacheString('{{bs}}', variables, 'json')).toBe('\\\\');
|
||||
expect(renderMustacheString('{{st}}', variables, 'json')).toBe(variables.st);
|
||||
expect(renderMustacheString('{{ul}}', variables, 'json')).toBe(variables.ul);
|
||||
});
|
||||
|
||||
it('handles errors', () => {
|
||||
expect(renderMustacheString('{{a}', variables, 'none')).toMatchInlineSnapshot(
|
||||
`"error rendering mustache template \\"{{a}\\": Unclosed tag at 4"`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const object = {
|
||||
literal: 0,
|
||||
literals: {
|
||||
a: 1,
|
||||
b: '2',
|
||||
c: true,
|
||||
d: null,
|
||||
e: undefined,
|
||||
eval: '{{lt}}{{b}}{{gt}}',
|
||||
},
|
||||
list: ['{{a}}', '{{bt}}{{st}}{{bt}}'],
|
||||
object: {
|
||||
a: ['{{a}}', '{{bt}}{{st}}{{bt}}'],
|
||||
},
|
||||
};
|
||||
|
||||
describe('renderMustacheObject()', () => {
|
||||
it('handles deep objects', () => {
|
||||
expect(renderMustacheObject(object, variables)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"list": Array [
|
||||
"1",
|
||||
"\`*\`",
|
||||
],
|
||||
"literal": 0,
|
||||
"literals": Object {
|
||||
"a": 1,
|
||||
"b": "2",
|
||||
"c": true,
|
||||
"d": null,
|
||||
"e": undefined,
|
||||
"eval": "<2>",
|
||||
},
|
||||
"object": Object {
|
||||
"a": Array [
|
||||
"1",
|
||||
"\`*\`",
|
||||
],
|
||||
},
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
it('handles primitive objects', () => {
|
||||
expect(renderMustacheObject(undefined, variables)).toMatchInlineSnapshot(`undefined`);
|
||||
expect(renderMustacheObject(null, variables)).toMatchInlineSnapshot(`null`);
|
||||
expect(renderMustacheObject(0, variables)).toMatchInlineSnapshot(`0`);
|
||||
expect(renderMustacheObject(true, variables)).toMatchInlineSnapshot(`true`);
|
||||
expect(renderMustacheObject('{{a}}', variables)).toMatchInlineSnapshot(`"1"`);
|
||||
expect(renderMustacheObject(['{{a}}'], variables)).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
"1",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('handles errors', () => {
|
||||
expect(renderMustacheObject({ a: '{{a}' }, variables)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"a": "error rendering mustache template \\"{{a}\\": Unclosed tag at 4",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
});
|
107
x-pack/plugins/actions/server/lib/mustache_renderer.ts
Normal file
107
x-pack/plugins/actions/server/lib/mustache_renderer.ts
Normal file
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* 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 Mustache from 'mustache';
|
||||
import { isString, cloneDeepWith } from 'lodash';
|
||||
|
||||
export type Escape = 'markdown' | 'slack' | 'json' | 'none';
|
||||
type Variables = Record<string, unknown>;
|
||||
|
||||
// return a rendered mustache template given the specified variables and escape
|
||||
export function renderMustacheString(string: string, variables: Variables, escape: Escape): string {
|
||||
const previousMustacheEscape = Mustache.escape;
|
||||
Mustache.escape = getEscape(escape);
|
||||
|
||||
try {
|
||||
return Mustache.render(`${string}`, variables);
|
||||
} catch (err) {
|
||||
// log error; the mustache code does not currently leak variables
|
||||
return `error rendering mustache template "${string}": ${err.message}`;
|
||||
} finally {
|
||||
Mustache.escape = previousMustacheEscape;
|
||||
}
|
||||
}
|
||||
|
||||
// return a cloned object with all strings rendered as mustache templates
|
||||
export function renderMustacheObject<Params>(params: Params, variables: Variables): Params {
|
||||
const result = cloneDeepWith(params, (value: unknown) => {
|
||||
if (!isString(value)) return;
|
||||
|
||||
// since we're rendering a JS object, no escaping needed
|
||||
return renderMustacheString(value, variables, 'none');
|
||||
});
|
||||
|
||||
// The return type signature for `cloneDeep()` ends up taking the return
|
||||
// type signature for the customizer, but rather than pollute the customizer
|
||||
// with casts, seemed better to just do it in one place, here.
|
||||
return (result as unknown) as Params;
|
||||
}
|
||||
|
||||
function getEscape(escape: Escape): (value: unknown) => string {
|
||||
if (escape === 'markdown') return escapeMarkdown;
|
||||
if (escape === 'slack') return escapeSlack;
|
||||
if (escape === 'json') return escapeJSON;
|
||||
return escapeNone;
|
||||
}
|
||||
|
||||
function escapeNone(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
// replace with JSON stringified version, removing leading and trailing double quote
|
||||
function escapeJSON(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
|
||||
const quoted = JSON.stringify(`${value}`);
|
||||
// quoted will always be a string with double quotes, but we don't want the double quotes
|
||||
return quoted.substr(1, quoted.length - 2);
|
||||
}
|
||||
|
||||
// see: https://api.slack.com/reference/surfaces/formatting
|
||||
// but in practice, a bit more needs to be escaped, in drastic ways
|
||||
function escapeSlack(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
|
||||
const valueString = `${value}`;
|
||||
// if the value contains * or _, escape the whole thing with back tics
|
||||
if (valueString.includes('_') || valueString.includes('*')) {
|
||||
// replace unescapable back tics with single quote
|
||||
return '`' + valueString.replace(/`/g, `'`) + '`';
|
||||
}
|
||||
|
||||
// otherwise, do "standard" escaping
|
||||
return (
|
||||
valueString
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// this isn't really standard escaping, but escaping back tics is problematic
|
||||
.replace(/`/g, `'`)
|
||||
);
|
||||
}
|
||||
|
||||
// see: https://www.markdownguide.org/basic-syntax/#characters-you-can-escape
|
||||
function escapeMarkdown(value: unknown): string {
|
||||
if (value == null) return '';
|
||||
|
||||
return `${value}`
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/`/g, '\\`')
|
||||
.replace(/\*/g, '\\*')
|
||||
.replace(/_/g, '\\_')
|
||||
.replace(/{/g, '\\{')
|
||||
.replace(/}/g, '\\}')
|
||||
.replace(/\[/g, '\\[')
|
||||
.replace(/\]/g, '\\]')
|
||||
.replace(/\(/g, '\\(')
|
||||
.replace(/\)/g, '\\)')
|
||||
.replace(/#/g, '\\#')
|
||||
.replace(/\+/g, '\\+')
|
||||
.replace(/-/g, '\\-')
|
||||
.replace(/\./g, '\\.')
|
||||
.replace(/!/g, '\\!');
|
||||
}
|
|
@ -5,14 +5,13 @@
|
|||
*/
|
||||
|
||||
import { actionsClientMock } from './actions_client.mock';
|
||||
import { PluginSetupContract, PluginStartContract } from './plugin';
|
||||
import { PluginSetupContract, PluginStartContract, renderActionParameterTemplates } from './plugin';
|
||||
import { Services } from './types';
|
||||
import {
|
||||
elasticsearchServiceMock,
|
||||
savedObjectsClientMock,
|
||||
} from '../../../../src/core/server/mocks';
|
||||
import { actionsAuthorizationMock } from './authorization/actions_authorization.mock';
|
||||
|
||||
export { actionsAuthorizationMock };
|
||||
export { actionsClientMock };
|
||||
|
||||
|
@ -32,10 +31,20 @@ const createStartMock = () => {
|
|||
.fn()
|
||||
.mockReturnValue(actionsAuthorizationMock.create()),
|
||||
preconfiguredActions: [],
|
||||
renderActionParameterTemplates: jest.fn(),
|
||||
};
|
||||
return mock;
|
||||
};
|
||||
|
||||
// this is a default renderer that escapes nothing
|
||||
export function renderActionParameterTemplatesDefault<RecordType>(
|
||||
actionTypeId: string,
|
||||
params: Record<string, unknown>,
|
||||
variables: Record<string, unknown>
|
||||
) {
|
||||
return renderActionParameterTemplates(undefined, actionTypeId, params, variables);
|
||||
}
|
||||
|
||||
const createServicesMock = () => {
|
||||
const mock: jest.Mocked<
|
||||
Services & {
|
||||
|
|
|
@ -75,6 +75,7 @@ import {
|
|||
AuthorizationMode,
|
||||
} from './authorization/get_authorization_mode_by_source';
|
||||
import { ensureSufficientLicense } from './lib/ensure_sufficient_license';
|
||||
import { renderMustacheObject } from './lib/mustache_renderer';
|
||||
|
||||
const EVENT_LOG_PROVIDER = 'actions';
|
||||
export const EVENT_LOG_ACTIONS = {
|
||||
|
@ -103,6 +104,11 @@ export interface PluginStartContract {
|
|||
getActionsClientWithRequest(request: KibanaRequest): Promise<PublicMethodsOf<ActionsClient>>;
|
||||
getActionsAuthorizationWithRequest(request: KibanaRequest): PublicMethodsOf<ActionsAuthorization>;
|
||||
preconfiguredActions: PreConfiguredAction[];
|
||||
renderActionParameterTemplates<Params extends ActionTypeParams = ActionTypeParams>(
|
||||
actionTypeId: string,
|
||||
params: Params,
|
||||
variables: Record<string, unknown>
|
||||
): Params;
|
||||
}
|
||||
|
||||
export interface ActionsPluginsSetup {
|
||||
|
@ -389,6 +395,8 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
|
|||
},
|
||||
getActionsClientWithRequest: secureGetActionsClientWithRequest,
|
||||
preconfiguredActions,
|
||||
renderActionParameterTemplates: (...args) =>
|
||||
renderActionParameterTemplates(actionTypeRegistry, ...args),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -484,3 +492,17 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function renderActionParameterTemplates<Params extends ActionTypeParams = ActionTypeParams>(
|
||||
actionTypeRegistry: ActionTypeRegistry | undefined,
|
||||
actionTypeId: string,
|
||||
params: Params,
|
||||
variables: Record<string, unknown>
|
||||
): Params {
|
||||
const actionType = actionTypeRegistry?.get(actionTypeId);
|
||||
if (actionType?.renderParameterTemplates) {
|
||||
return actionType.renderParameterTemplates(params, variables) as Params;
|
||||
} else {
|
||||
return renderMustacheObject(params, variables);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -112,6 +112,7 @@ export interface ActionType<
|
|||
config?: ValidatorType<Config>;
|
||||
secrets?: ValidatorType<Secrets>;
|
||||
};
|
||||
renderParameterTemplates?(params: Params, variables: Record<string, unknown>): Params;
|
||||
executor: ExecutorType<Config, Secrets, Params, ExecutorResultData>;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# This will create 3 actions and 1 alert that runs those actions.
|
||||
# The actions run will need to do action-specific escaping for the
|
||||
# actions to work correctly, which was fixed in 7.11.0.
|
||||
#
|
||||
# The actions run are Slack, Webhook, and email. The Webhook action also
|
||||
# posts to the same Slack webhook. The email posts to maildev.
|
||||
#
|
||||
# After running the script, check Slack and the maildev web interface
|
||||
# to make sure the actions ran appropriately. You can also edit the
|
||||
# alert name to other interesting text to see how it renders.
|
||||
#
|
||||
# you will need the following env vars set for Slack:
|
||||
# SLACK_WEBHOOKURL
|
||||
#
|
||||
# expects you're running maildev with the default options via
|
||||
# npx maildev
|
||||
#
|
||||
# you'll need jq installed
|
||||
# https://stedolan.github.io/jq/download/
|
||||
|
||||
KIBANA_URL=https://elastic:changeme@localhost:5601
|
||||
|
||||
# create email action
|
||||
ACTION_ID_EMAIL=`curl -X POST --insecure --silent \
|
||||
$KIBANA_URL/api/actions/action \
|
||||
-H "kbn-xsrf: foo" -H "content-type: application/json" \
|
||||
-d '{
|
||||
"actionTypeId": ".email",
|
||||
"name": "email for action_param_templates test",
|
||||
"config": {
|
||||
"from": "team-alerting@example.com",
|
||||
"host": "localhost",
|
||||
"port": 1025
|
||||
},
|
||||
"secrets": {
|
||||
}
|
||||
}' | jq -r '.id'`
|
||||
echo "email action id: $ACTION_ID_EMAIL"
|
||||
|
||||
# create slack action
|
||||
ACTION_ID_SLACK=`curl -X POST --insecure --silent \
|
||||
$KIBANA_URL/api/actions/action \
|
||||
-H "kbn-xsrf: foo" -H "content-type: application/json" \
|
||||
-d "{
|
||||
\"actionTypeId\": \".slack\",
|
||||
\"name\": \"slack for action_param_templates test\",
|
||||
\"config\": {
|
||||
},
|
||||
\"secrets\": {
|
||||
\"webhookUrl\": \"$SLACK_WEBHOOKURL\"
|
||||
}
|
||||
}" | jq -r '.id'`
|
||||
echo "slack action id: $ACTION_ID_SLACK"
|
||||
|
||||
# create webhook action
|
||||
ACTION_ID_WEBHOOK=`curl -X POST --insecure --silent \
|
||||
$KIBANA_URL/api/actions/action \
|
||||
-H "kbn-xsrf: foo" -H "content-type: application/json" \
|
||||
-d "{
|
||||
\"actionTypeId\": \".webhook\",
|
||||
\"name\": \"webhook for action_param_templates test\",
|
||||
\"config\": {
|
||||
\"url\": \"$SLACK_WEBHOOKURL\",
|
||||
\"headers\": { \"Content-type\": \"application/json\" }
|
||||
},
|
||||
\"secrets\": {
|
||||
}
|
||||
}" | jq -r '.id'`
|
||||
echo "webhook action id: $ACTION_ID_WEBHOOK"
|
||||
|
||||
WEBHOOK_BODY="{ \\\"text\\\": \\\"text from webhook {{alertName}}\\\" }"
|
||||
|
||||
# create alert
|
||||
ALERT_ID=`curl -X POST --insecure --silent \
|
||||
$KIBANA_URL/api/alerts/alert \
|
||||
-H "kbn-xsrf: foo" -H "content-type: application/json" \
|
||||
-d "{
|
||||
\"alertTypeId\": \".index-threshold\",
|
||||
\"name\": \"alert for action_param_templates test\u000awith newline and *bold*\",
|
||||
\"schedule\": { \"interval\": \"30s\" },
|
||||
\"consumer\": \"alerts\",
|
||||
\"tags\": [],
|
||||
\"actions\": [
|
||||
{
|
||||
\"group\": \"threshold met\",
|
||||
\"id\": \"$ACTION_ID_EMAIL\",
|
||||
\"params\":{
|
||||
\"to\": [\"team-alerting@example.com\"],
|
||||
\"subject\": \"subject {{alertName}}\",
|
||||
\"message\": \"message {{alertName}}\"
|
||||
}
|
||||
},
|
||||
{
|
||||
\"group\": \"threshold met\",
|
||||
\"id\": \"$ACTION_ID_SLACK\",
|
||||
\"params\":{
|
||||
\"message\": \"message from slack {{alertName}}\"
|
||||
}
|
||||
},
|
||||
{
|
||||
\"group\": \"threshold met\",
|
||||
\"id\": \"$ACTION_ID_WEBHOOK\",
|
||||
\"params\":{
|
||||
\"body\": \"$WEBHOOK_BODY\"
|
||||
}
|
||||
}
|
||||
],
|
||||
\"params\": {
|
||||
\"index\": [\".kibana\"],
|
||||
\"timeField\": \"updated_at\",
|
||||
\"aggType\": \"count\",
|
||||
\"groupBy\": \"all\",
|
||||
\"timeWindowSize\": 100,
|
||||
\"timeWindowUnit\": \"d\",
|
||||
\"thresholdComparator\": \">\",
|
||||
\"threshold\":[0]
|
||||
}
|
||||
}" #| jq -r '.id'`
|
||||
echo "alert id: $ALERT_ID"
|
|
@ -7,7 +7,11 @@
|
|||
import { AlertType } from '../types';
|
||||
import { createExecutionHandler } from './create_execution_handler';
|
||||
import { loggingSystemMock } from '../../../../../src/core/server/mocks';
|
||||
import { actionsMock, actionsClientMock } from '../../../actions/server/mocks';
|
||||
import {
|
||||
actionsMock,
|
||||
actionsClientMock,
|
||||
renderActionParameterTemplatesDefault,
|
||||
} from '../../../actions/server/mocks';
|
||||
import { eventLoggerMock } from '../../../event_log/server/event_logger.mock';
|
||||
import { KibanaRequest } from 'kibana/server';
|
||||
import { asSavedObjectExecutionSource } from '../../../actions/server';
|
||||
|
@ -69,6 +73,9 @@ beforeEach(() => {
|
|||
createExecutionHandlerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue(
|
||||
actionsClient
|
||||
);
|
||||
createExecutionHandlerParams.actionsPlugin.renderActionParameterTemplates.mockImplementation(
|
||||
renderActionParameterTemplatesDefault
|
||||
);
|
||||
});
|
||||
|
||||
test('enqueues execution per selected action', async () => {
|
||||
|
|
|
@ -78,7 +78,9 @@ export function createExecutionHandler({
|
|||
return {
|
||||
...action,
|
||||
params: transformActionParams({
|
||||
actionsPlugin,
|
||||
alertId,
|
||||
actionTypeId: action.actionTypeId,
|
||||
alertName,
|
||||
spaceId,
|
||||
tags,
|
||||
|
|
|
@ -137,6 +137,9 @@ describe('Task Runner', () => {
|
|||
taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue(
|
||||
actionsClient
|
||||
);
|
||||
taskRunnerFactoryInitializerParams.actionsPlugin.renderActionParameterTemplates.mockImplementation(
|
||||
(actionTypeId, params) => params
|
||||
);
|
||||
});
|
||||
|
||||
test('successfully executes the task', async () => {
|
||||
|
|
|
@ -5,6 +5,17 @@
|
|||
*/
|
||||
|
||||
import { transformActionParams } from './transform_action_params';
|
||||
import { actionsMock, renderActionParameterTemplatesDefault } from '../../../actions/server/mocks';
|
||||
|
||||
const actionsPlugin = actionsMock.createStart();
|
||||
const actionTypeId = 'test-actionTypeId';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
actionsPlugin.renderActionParameterTemplates.mockImplementation(
|
||||
renderActionParameterTemplatesDefault
|
||||
);
|
||||
});
|
||||
|
||||
test('skips non string parameters', () => {
|
||||
const actionParams = {
|
||||
|
@ -16,6 +27,8 @@ test('skips non string parameters', () => {
|
|||
message: 'Value "{{params.foo}}" exists',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
context: {},
|
||||
state: {},
|
||||
|
@ -48,6 +61,8 @@ test('missing parameters get emptied out', () => {
|
|||
message2: 'This message "{{context.value2}}" is missing',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
context: {},
|
||||
state: {},
|
||||
|
@ -73,6 +88,8 @@ test('context parameters are passed to templates', () => {
|
|||
message: 'Value "{{context.foo}}" exists',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: {},
|
||||
context: { foo: 'fooVal' },
|
||||
|
@ -97,6 +114,8 @@ test('state parameters are passed to templates', () => {
|
|||
message: 'Value "{{state.bar}}" exists',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: { bar: 'barVal' },
|
||||
context: {},
|
||||
|
@ -121,6 +140,8 @@ test('alertId is passed to templates', () => {
|
|||
message: 'Value "{{alertId}}" exists',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: {},
|
||||
context: {},
|
||||
|
@ -145,6 +166,8 @@ test('alertName is passed to templates', () => {
|
|||
message: 'Value "{{alertName}}" exists',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: {},
|
||||
context: {},
|
||||
|
@ -169,6 +192,8 @@ test('tags is passed to templates', () => {
|
|||
message: 'Value "{{tags}}" exists',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: {},
|
||||
context: {},
|
||||
|
@ -193,6 +218,8 @@ test('undefined tags is passed to templates', () => {
|
|||
message: 'Value "{{tags}}" is undefined and renders as empty string',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: {},
|
||||
context: {},
|
||||
|
@ -216,6 +243,8 @@ test('empty tags is passed to templates', () => {
|
|||
message: 'Value "{{tags}}" is an empty array and renders as empty string',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: {},
|
||||
context: {},
|
||||
|
@ -240,6 +269,8 @@ test('spaceId is passed to templates', () => {
|
|||
message: 'Value "{{spaceId}}" exists',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: {},
|
||||
context: {},
|
||||
|
@ -264,6 +295,8 @@ test('alertInstanceId is passed to templates', () => {
|
|||
message: 'Value "{{alertInstanceId}}" exists',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: {},
|
||||
context: {},
|
||||
|
@ -288,6 +321,8 @@ test('alertActionGroup is passed to templates', () => {
|
|||
message: 'Value "{{alertActionGroup}}" exists',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: {},
|
||||
context: {},
|
||||
|
@ -312,6 +347,8 @@ test('alertActionGroupName is passed to templates', () => {
|
|||
message: 'Value "{{alertActionGroupName}}" exists',
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: {},
|
||||
context: {},
|
||||
|
@ -337,6 +374,8 @@ test('date is passed to templates', () => {
|
|||
};
|
||||
const dateBefore = Date.now();
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: {},
|
||||
context: {},
|
||||
|
@ -363,6 +402,8 @@ test('works recursively', () => {
|
|||
},
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: { value: 'state' },
|
||||
context: { value: 'context' },
|
||||
|
@ -391,6 +432,8 @@ test('works recursively with arrays', () => {
|
|||
},
|
||||
};
|
||||
const result = transformActionParams({
|
||||
actionsPlugin,
|
||||
actionTypeId,
|
||||
actionParams,
|
||||
state: { value: 'state' },
|
||||
context: { value: 'context' },
|
||||
|
|
|
@ -4,17 +4,18 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import Mustache from 'mustache';
|
||||
import { isString, cloneDeepWith } from 'lodash';
|
||||
import {
|
||||
AlertActionParams,
|
||||
AlertInstanceState,
|
||||
AlertInstanceContext,
|
||||
AlertTypeParams,
|
||||
} from '../types';
|
||||
import { PluginStartContract as ActionsPluginStartContract } from '../../../actions/server';
|
||||
|
||||
interface TransformActionParamsOptions {
|
||||
actionsPlugin: ActionsPluginStartContract;
|
||||
alertId: string;
|
||||
actionTypeId: string;
|
||||
alertName: string;
|
||||
spaceId: string;
|
||||
tags?: string[];
|
||||
|
@ -29,7 +30,9 @@ interface TransformActionParamsOptions {
|
|||
}
|
||||
|
||||
export function transformActionParams({
|
||||
actionsPlugin,
|
||||
alertId,
|
||||
actionTypeId,
|
||||
alertName,
|
||||
spaceId,
|
||||
tags,
|
||||
|
@ -42,31 +45,22 @@ export function transformActionParams({
|
|||
state,
|
||||
alertParams,
|
||||
}: TransformActionParamsOptions): AlertActionParams {
|
||||
const result = cloneDeepWith(actionParams, (value: unknown) => {
|
||||
if (!isString(value)) return;
|
||||
|
||||
// when the list of variables we pass in here changes,
|
||||
// the UI will need to be updated as well; see:
|
||||
// x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts
|
||||
const variables = {
|
||||
alertId,
|
||||
alertName,
|
||||
spaceId,
|
||||
tags,
|
||||
alertInstanceId,
|
||||
alertActionGroup,
|
||||
alertActionGroupName,
|
||||
alertActionSubgroup,
|
||||
context,
|
||||
date: new Date().toISOString(),
|
||||
state,
|
||||
params: alertParams,
|
||||
};
|
||||
return Mustache.render(value, variables);
|
||||
});
|
||||
|
||||
// The return type signature for `cloneDeep()` ends up taking the return
|
||||
// type signature for the customizer, but rather than pollute the customizer
|
||||
// with casts, seemed better to just do it in one place, here.
|
||||
return (result as unknown) as AlertActionParams;
|
||||
// when the list of variables we pass in here changes,
|
||||
// the UI will need to be updated as well; see:
|
||||
// x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts
|
||||
const variables = {
|
||||
alertId,
|
||||
alertName,
|
||||
spaceId,
|
||||
tags,
|
||||
alertInstanceId,
|
||||
alertActionGroup,
|
||||
alertActionGroupName,
|
||||
alertActionSubgroup,
|
||||
context,
|
||||
date: new Date().toISOString(),
|
||||
state,
|
||||
params: alertParams,
|
||||
};
|
||||
return actionsPlugin.renderActionParameterTemplates(actionTypeId, actionParams, variables);
|
||||
}
|
||||
|
|
|
@ -7,7 +7,17 @@
|
|||
import http from 'http';
|
||||
|
||||
export async function initPlugin() {
|
||||
const messages: string[] = [];
|
||||
|
||||
return http.createServer((request, response) => {
|
||||
// return the messages that were posted to be remembered
|
||||
if (request.method === 'GET') {
|
||||
response.statusCode = 200;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(messages, null, 4));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST') {
|
||||
let data = '';
|
||||
request.on('data', (chunk) => {
|
||||
|
@ -15,7 +25,7 @@ export async function initPlugin() {
|
|||
});
|
||||
request.on('end', () => {
|
||||
const body = JSON.parse(data);
|
||||
const text = body && body.text;
|
||||
const text: string = body && body.text;
|
||||
|
||||
if (text == null) {
|
||||
response.statusCode = 400;
|
||||
|
@ -23,6 +33,15 @@ export async function initPlugin() {
|
|||
return;
|
||||
}
|
||||
|
||||
// store a message that was posted to be remembered
|
||||
const match = text.match(/^message (.*)$/);
|
||||
if (match) {
|
||||
messages.push(match[1]);
|
||||
response.statusCode = 200;
|
||||
response.end('ok');
|
||||
return;
|
||||
}
|
||||
|
||||
switch (text) {
|
||||
case 'success': {
|
||||
response.statusCode = 200;
|
||||
|
|
|
@ -10,6 +10,8 @@ import { pipe } from 'fp-ts/lib/pipeable';
|
|||
import { constant } from 'fp-ts/lib/function';
|
||||
|
||||
export async function initPlugin() {
|
||||
const payloads: string[] = [];
|
||||
|
||||
return http.createServer((request, response) => {
|
||||
const credentials = pipe(
|
||||
fromNullable(request.headers.authorization),
|
||||
|
@ -24,6 +26,14 @@ export async function initPlugin() {
|
|||
getOrElse(constant({ username: '', password: '' }))
|
||||
);
|
||||
|
||||
// return the payloads that were posted to be remembered
|
||||
if (request.method === 'GET') {
|
||||
response.statusCode = 200;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(payloads, null, 4));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === 'POST' || request.method === 'PUT') {
|
||||
let data = '';
|
||||
request.on('data', (chunk) => {
|
||||
|
@ -46,10 +56,18 @@ export async function initPlugin() {
|
|||
response.end('Error');
|
||||
return;
|
||||
}
|
||||
|
||||
// store a payload that was posted to be remembered
|
||||
const match = data.match(/^payload (.*)$/);
|
||||
if (match) {
|
||||
payloads.push(match[1]);
|
||||
response.statusCode = 200;
|
||||
response.end('ok');
|
||||
return;
|
||||
}
|
||||
|
||||
response.statusCode = 400;
|
||||
response.end(
|
||||
`unknown request to webhook simulator [${data ? `content: ${data}` : `no content`}]`
|
||||
);
|
||||
response.end(`unexpected body ${data}`);
|
||||
return;
|
||||
});
|
||||
} else {
|
||||
|
|
|
@ -15,6 +15,15 @@ import {
|
|||
AlertInstanceContext,
|
||||
} from '../../../../../../../plugins/alerts/server';
|
||||
|
||||
export const EscapableStrings = {
|
||||
escapableBold: '*bold*',
|
||||
escapableBacktic: 'back`tic',
|
||||
escapableBackticBold: '`*bold*`',
|
||||
escapableHtml: '<&>',
|
||||
escapableDoubleQuote: '"double quote"',
|
||||
escapableLineFeed: 'line\x0afeed',
|
||||
};
|
||||
|
||||
function getAlwaysFiringAlertType() {
|
||||
const paramsSchema = schema.object({
|
||||
index: schema.string(),
|
||||
|
@ -394,7 +403,7 @@ function getPatternFiringAlertType() {
|
|||
for (const [instanceId, instancePattern] of Object.entries(pattern)) {
|
||||
const scheduleByPattern = instancePattern[patternIndex];
|
||||
if (scheduleByPattern === true) {
|
||||
services.alertInstanceFactory(instanceId).scheduleActions('default');
|
||||
services.alertInstanceFactory(instanceId).scheduleActions('default', EscapableStrings);
|
||||
} else if (typeof scheduleByPattern === 'string') {
|
||||
services
|
||||
.alertInstanceFactory(instanceId)
|
||||
|
|
|
@ -34,6 +34,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
|
|||
loadTestFile(require.resolve('./alerts_space1'));
|
||||
loadTestFile(require.resolve('./alerts_default_space'));
|
||||
loadTestFile(require.resolve('./builtin_alert_types'));
|
||||
loadTestFile(require.resolve('./mustache_templates.ts'));
|
||||
loadTestFile(require.resolve('./notify_when'));
|
||||
|
||||
// note that this test will destroy existing spaces
|
||||
|
|
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/*
|
||||
* These tests ensure that the per-action mustache template escaping works
|
||||
* for actions we have simulators for. It arranges to have an alert that
|
||||
* schedules an action that will contain "escapable" characters in it, and
|
||||
* then validates that the simulator receives the escaped versions.
|
||||
*/
|
||||
|
||||
import http from 'http';
|
||||
import getPort from 'get-port';
|
||||
import { URL, format as formatUrl } from 'url';
|
||||
import axios from 'axios';
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { Spaces } from '../../scenarios';
|
||||
import { getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib';
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
import {
|
||||
getWebhookServer,
|
||||
getSlackServer,
|
||||
} from '../../../common/fixtures/plugins/actions_simulators/server/plugin';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function executionStatusAlertTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const retry = getService('retry');
|
||||
|
||||
describe('mustacheTemplates', () => {
|
||||
const objectRemover = new ObjectRemover(supertest);
|
||||
let webhookSimulatorURL: string = '';
|
||||
let webhookServer: http.Server;
|
||||
let slackSimulatorURL: string = '';
|
||||
let slackServer: http.Server;
|
||||
|
||||
before(async () => {
|
||||
let availablePort: number;
|
||||
|
||||
webhookServer = await getWebhookServer();
|
||||
availablePort = await getPort({ port: 9000 });
|
||||
webhookServer.listen(availablePort);
|
||||
webhookSimulatorURL = `http://localhost:${availablePort}`;
|
||||
|
||||
slackServer = await getSlackServer();
|
||||
availablePort = await getPort({ port: getPort.makeRange(9000, 9100) });
|
||||
if (!slackServer.listening) {
|
||||
slackServer.listen(availablePort);
|
||||
}
|
||||
slackSimulatorURL = `http://localhost:${availablePort}`;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await objectRemover.removeAll();
|
||||
webhookServer.close();
|
||||
slackServer.close();
|
||||
});
|
||||
|
||||
it('should handle escapes in webhook', async () => {
|
||||
const url = formatUrl(new URL(webhookSimulatorURL), { auth: false });
|
||||
const actionResponse = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`)
|
||||
.set('kbn-xsrf', 'test')
|
||||
.send({
|
||||
name: 'testing mustache escapes for webhook',
|
||||
actionTypeId: '.webhook',
|
||||
secrets: {},
|
||||
config: {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
url,
|
||||
},
|
||||
});
|
||||
expect(actionResponse.status).to.eql(200);
|
||||
const createdAction = actionResponse.body;
|
||||
objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions');
|
||||
|
||||
// from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts
|
||||
const varsTemplate = '{{context.escapableDoubleQuote}} -- {{context.escapableLineFeed}}';
|
||||
|
||||
const alertResponse = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestAlertData({
|
||||
name: 'testing variable escapes for webhook',
|
||||
alertTypeId: 'test.patternFiring',
|
||||
params: {
|
||||
pattern: { instance: [true] },
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createdAction.id,
|
||||
group: 'default',
|
||||
params: {
|
||||
body: `payload {{alertId}} - ${varsTemplate}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(alertResponse.status).to.eql(200);
|
||||
const createdAlert = alertResponse.body;
|
||||
objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts');
|
||||
|
||||
const body = await retry.try(async () =>
|
||||
waitForActionBody(webhookSimulatorURL, createdAlert.id)
|
||||
);
|
||||
expect(body).to.be(`\\"double quote\\" -- line\\nfeed`);
|
||||
});
|
||||
|
||||
it('should handle escapes in slack', async () => {
|
||||
const actionResponse = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`)
|
||||
.set('kbn-xsrf', 'test')
|
||||
.send({
|
||||
name: "testing backtic'd mustache escapes for slack",
|
||||
actionTypeId: '.slack',
|
||||
secrets: {
|
||||
webhookUrl: slackSimulatorURL,
|
||||
},
|
||||
});
|
||||
expect(actionResponse.status).to.eql(200);
|
||||
const createdAction = actionResponse.body;
|
||||
objectRemover.add(Spaces.space1.id, createdAction.id, 'action', 'actions');
|
||||
|
||||
// from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts
|
||||
const varsTemplate =
|
||||
'{{context.escapableBacktic}} -- {{context.escapableBold}} -- {{context.escapableBackticBold}} -- {{context.escapableHtml}}';
|
||||
|
||||
const alertResponse = await supertest
|
||||
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestAlertData({
|
||||
name: 'testing variable escapes for slack',
|
||||
alertTypeId: 'test.patternFiring',
|
||||
params: {
|
||||
pattern: { instance: [true] },
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createdAction.id,
|
||||
group: 'default',
|
||||
params: {
|
||||
message: `message {{alertId}} - ${varsTemplate}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
expect(alertResponse.status).to.eql(200);
|
||||
const createdAlert = alertResponse.body;
|
||||
objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts');
|
||||
|
||||
const body = await retry.try(async () =>
|
||||
waitForActionBody(slackSimulatorURL, createdAlert.id)
|
||||
);
|
||||
expect(body).to.be("back'tic -- `*bold*` -- `'*bold*'` -- <&>");
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForActionBody(url: string, id: string): Promise<string> {
|
||||
const response = await axios.get<string[]>(url);
|
||||
expect(response.status).to.eql(200);
|
||||
|
||||
for (const datum of response.data) {
|
||||
const match = datum.match(/^(.*) - (.*)$/);
|
||||
if (match == null) continue;
|
||||
|
||||
if (match[1] === id) return match[2];
|
||||
}
|
||||
|
||||
throw new Error(`no action body posted yet for id ${id}`);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue