diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 132510ea0ce84..a9c2430c4f395 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -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 [], + } + `); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index be2664887d943..06f18916d7ee5 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -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 +): 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( diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts index d98a41ed1f355..cc2c0eda76f52 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.test.ts @@ -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*`'); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/plugins/actions/server/builtin_action_types/slack.ts index 628a13e19f7a9..a9155c329c175 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/slack.ts @@ -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 +): ActionParamsType { + return { + message: renderMustacheString(params.message, variables, 'slack'), + }; +} + function valdiateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, secretsObject: ActionTypeSecretsType diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts index 74feb8ee57d48..dbbd2a029caa9 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -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"}`); + }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts index dc9de86d3d951..3d872d6e7e311 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/webhook.ts @@ -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 +): ActionParamsType { + if (!params.body) return params; + return { + body: renderMustacheString(params.body, variables, 'json'), + }; +} + function validateActionTypeConfig( configurationUtilities: ActionsConfigurationUtilities, configObject: ActionTypeConfigType diff --git a/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts b/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts new file mode 100644 index 0000000000000..933cb645b117d --- /dev/null +++ b/x-pack/plugins/actions/server/lib/mustache_renderer.test.ts @@ -0,0 +1,187 @@ +/* + * 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 } from './mustache_renderer'; + +const variables = { + a: 1, + b: '2', + c: false, + d: null, + e: undefined, + f: { + g: 3, + }, + lt: '<', + gt: '>', + amp: '&', + nl: '\n', + dq: '"', + bt: '`', + bs: '\\', + st: '*', + ul: '_', + st_lt: '*<', +}; + +describe('mustache_renderer', () => { + describe('renderMustacheString()', () => { + it('handles basic templating that does not need escaping', () => { + expect(renderMustacheString('', variables, 'none')).toBe(''); + expect(renderMustacheString('{{a}}', variables, 'none')).toBe('1'); + expect(renderMustacheString('{{b}}', variables, 'none')).toBe('2'); + expect(renderMustacheString('{{c}}', variables, 'none')).toBe('false'); + expect(renderMustacheString('{{d}}', variables, 'none')).toBe(''); + expect(renderMustacheString('{{e}}', variables, 'none')).toBe(''); + expect(renderMustacheString('{{f.g}}', variables, 'none')).toBe('3'); + }); + + 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", + } + `); + }); + }); + + describe('augmented object variables', () => { + const deepVariables = { + a: 1, + b: { c: 2, d: [3, 4] }, + e: [5, { f: 6, g: 7 }], + }; + expect(renderMustacheObject({ x: '{{a}} - {{b}} -- {{e}} ' }, deepVariables)) + .toMatchInlineSnapshot(` + Object { + "x": "1 - {\\"c\\":2,\\"d\\":[3,4]} -- 5,{\\"f\\":6,\\"g\\":7} ", + } + `); + + const expected = '1 - {"c":2,"d":[3,4]} -- 5,{"f":6,"g":7}'; + expect(renderMustacheString('{{a}} - {{b}} -- {{e}}', deepVariables, 'none')).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/mustache_renderer.ts b/x-pack/plugins/actions/server/lib/mustache_renderer.ts new file mode 100644 index 0000000000000..e192142091da5 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/mustache_renderer.ts @@ -0,0 +1,135 @@ +/* + * 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, isPlainObject, cloneDeepWith } from 'lodash'; + +type Escape = 'markdown' | 'slack' | 'json' | 'none'; +type Variables = Record; + +// return a rendered mustache template given the specified variables and escape +export function renderMustacheString(string: string, variables: Variables, escape: Escape): string { + const augmentedVariables = augmentObjectVariables(variables); + + const previousMustacheEscape = Mustache.escape; + Mustache.escape = getEscape(escape); + + try { + return Mustache.render(`${string}`, augmentedVariables); + } 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, variables: Variables): Params { + const augmentedVariables = augmentObjectVariables(variables); + const result = cloneDeepWith(params, (value: unknown) => { + if (!isString(value)) return; + + // since we're rendering a JS object, no escaping needed + return renderMustacheString(value, augmentedVariables, '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; +} + +// return variables cloned, with a toString() added to objects +function augmentObjectVariables(variables: Variables): Variables { + const result = JSON.parse(JSON.stringify(variables)); + addToStringDeep(result); + return result; +} + +function addToStringDeep(object: unknown): void { + // for objects, add a toString method, and then walk + if (isNonNullObject(object)) { + if (!object.hasOwnProperty('toString')) { + object.toString = () => JSON.stringify(object); + } + Object.values(object).forEach((value) => addToStringDeep(value)); + } + + // walk arrays, but don't add a toString() as mustache already does something + if (Array.isArray(object)) { + object.forEach((element) => addToStringDeep(element)); + return; + } +} + +function isNonNullObject(object: unknown): object is Record { + if (object == null) return false; + if (typeof object !== 'object') return false; + if (!isPlainObject(object)) return false; + return true; +} + +function getEscape(escape: Escape): (string: string) => string { + if (escape === 'markdown') return escapeMarkdown; + if (escape === 'slack') return escapeSlack; + if (escape === 'json') return escapeJSON; + return escapeNone; +} + +function escapeNone(value: string): string { + return value; +} + +// replace with JSON stringified version, removing leading and trailing double quote +function escapeJSON(value: string): 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: string): string { + // if the value contains * or _, escape the whole thing with back tics + if (value.includes('_') || value.includes('*')) { + // replace unescapable back tics with single quote + value = value.replace(/`/g, `'`); + return '`' + value + '`'; + } + + // otherwise, do "standard" escaping + value = value + .replace(/&/g, '&') + .replace(//g, '>') + // this isn't really standard escaping, but escaping back tics is problematic + .replace(/`/g, `'`); + + return value; +} + +// see: https://www.markdownguide.org/basic-syntax/#characters-you-can-escape +function escapeMarkdown(value: string): string { + 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, '\\!'); +} diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index ad1c51d06d0c0..a766b5aa1776b 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -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( + actionTypeId: string, + params: Record, + variables: Record +) { + return renderActionParameterTemplates(undefined, actionTypeId, params, variables); +} + const createServicesMock = () => { const mock: jest.Mocked< Services & { diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 6e37d4bd7a92a..4d52b1c8b3492 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -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>; getActionsAuthorizationWithRequest(request: KibanaRequest): PublicMethodsOf; preconfiguredActions: PreConfiguredAction[]; + renderActionParameterTemplates( + actionTypeId: string, + params: Params, + variables: Record + ): Params; } export interface ActionsPluginsSetup { @@ -389,6 +395,8 @@ export class ActionsPlugin implements Plugin, Plugi }, getActionsClientWithRequest: secureGetActionsClientWithRequest, preconfiguredActions, + renderActionParameterTemplates: (...args) => + renderActionParameterTemplates(actionTypeRegistry, ...args), }; } @@ -484,3 +492,17 @@ export class ActionsPlugin implements Plugin, Plugi } } } + +export function renderActionParameterTemplates( + actionTypeRegistry: ActionTypeRegistry | undefined, + actionTypeId: string, + params: Params, + variables: Record +): Params { + const actionType = actionTypeRegistry?.get(actionTypeId); + if (actionType?.renderParameterTemplates) { + return actionType.renderParameterTemplates(params, variables) as Params; + } else { + return renderMustacheObject(params, variables); + } +} diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts index 79895195d90f3..f55b088c4d3f6 100644 --- a/x-pack/plugins/actions/server/types.ts +++ b/x-pack/plugins/actions/server/types.ts @@ -112,6 +112,7 @@ export interface ActionType< config?: ValidatorType; secrets?: ValidatorType; }; + renderParameterTemplates?(params: Params, variables: Record): Params; executor: ExecutorType; } diff --git a/x-pack/plugins/alerts/server/manual_tests/action_param_templates.sh b/x-pack/plugins/alerts/server/manual_tests/action_param_templates.sh new file mode 100644 index 0000000000000..5b209fdd3f598 --- /dev/null +++ b/x-pack/plugins/alerts/server/manual_tests/action_param_templates.sh @@ -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" diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts index 59eca88a9ada3..7c3e003de461e 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts @@ -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 () => { diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index 3d68ba3adbd6b..9c67d9666995f 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -71,7 +71,9 @@ export function createExecutionHandler({ return { ...action, params: transformActionParams({ + actionsPlugin, alertId, + actionTypeId: action.actionTypeId, alertName, spaceId, tags, diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index a2b281036d4cc..3a7f6f0cde4ff 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -136,6 +136,9 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue( actionsClient ); + taskRunnerFactoryInitializerParams.actionsPlugin.renderActionParameterTemplates.mockImplementation( + (actionTypeId, params) => params + ); }); test('successfully executes the task', async () => { diff --git a/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts b/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts index 782b9fc07207b..39468c2913b5f 100644 --- a/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts @@ -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' }, diff --git a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts index fa4a5b7f1b4ab..1fd53ee62c5bc 100644 --- a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts +++ b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts @@ -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[]; @@ -28,7 +29,9 @@ interface TransformActionParamsOptions { } export function transformActionParams({ + actionsPlugin, alertId, + actionTypeId, alertName, spaceId, tags, @@ -40,30 +43,21 @@ 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, - 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, + context, + date: new Date().toISOString(), + state, + params: alertParams, + }; + return actionsPlugin.renderActionParameterTemplates(actionTypeId, actionParams, variables); } diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts index 8f5b1ea75d188..dcbfff81cd85d 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/slack_simulation.ts @@ -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; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts index 44d8ea0c2da20..a34293090d7af 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/webhook_simulation.ts @@ -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 { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index d8a0f279222c7..a45ce1af8bb30 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -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')); // note that this test will destroy existing spaces loadTestFile(require.resolve('./migrations')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts new file mode 100644 index 0000000000000..db8fb2035d2e0 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/mustache_templates.ts @@ -0,0 +1,219 @@ +/* + * 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'); + + const alertResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + name: 'contains "double quote"', + alertTypeId: 'test.patternFiring', + params: { + pattern: { instance: [true] }, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: { + body: 'payload {{alertId}} - {{alertName}}', + }, + }, + ], + }) + ); + 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(`contains \\"double quote\\"`); + }); + + it('should handle bold and italic 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'); + + const alertResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + name: 'contains *bold* and _italic_ and back`tic and htmlish <&> things', + alertTypeId: 'test.patternFiring', + params: { + pattern: { instance: [true] }, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: { + message: 'message {{alertId}} - {{alertName}}', + }, + }, + ], + }) + ); + 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("`contains *bold* and _italic_ and back'tic and htmlish <&> things`"); + }); + + it('should handle single escapes in slack', async () => { + const actionResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`) + .set('kbn-xsrf', 'test') + .send({ + name: 'testing single 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'); + + const alertResponse = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + name: 'contains back`tic and htmlish <&> things', + alertTypeId: 'test.patternFiring', + params: { + pattern: { instance: [true] }, + }, + actions: [ + { + id: createdAction.id, + group: 'default', + params: { + message: 'message {{alertId}} - {{alertName}}', + }, + }, + ], + }) + ); + 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("contains back'tic and htmlish <&> things"); + }); + }); + + async function waitForActionBody(url: string, id: string): Promise { + const response = await axios.get(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}`); + } +}