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:
Patrick Mueller 2020-12-14 20:41:13 -05:00 committed by GitHub
parent 5b723ad206
commit 7873e3685b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 862 additions and 37 deletions

View file

@ -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 [],
}
`);
});
});

View file

@ -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(

View file

@ -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*`');
});
});

View file

@ -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

View file

@ -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"}`);
});
});

View file

@ -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

View 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('&lt;');
expect(renderMustacheString('{{gt}}', variables, 'slack')).toBe('&gt;');
expect(renderMustacheString('{{amp}}', variables, 'slack')).toBe('&amp;');
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",
}
`);
});
});
});

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
// 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, '\\!');
}

View file

@ -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 & {

View file

@ -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);
}
}

View file

@ -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>;
}

View file

@ -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"

View file

@ -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 () => {

View file

@ -78,7 +78,9 @@ export function createExecutionHandler({
return {
...action,
params: transformActionParams({
actionsPlugin,
alertId,
actionTypeId: action.actionTypeId,
alertName,
spaceId,
tags,

View file

@ -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 () => {

View file

@ -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' },

View file

@ -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);
}

View file

@ -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;

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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*'` -- &lt;&amp;&gt;");
});
});
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}`);
}
}