[actions] expand object context variables as JSON (#85903)

resolves https://github.com/elastic/kibana/issues/75601

Previously, if a context variable that is an object is referenced in a
mustache template used as an action parameter, the resulting variable
expansion will be `[Object object]`.  In this PR, we change this so that
the expansion is a JSON representation of the object.

This is primarily for diagnostic purposes, so that customers can see
all the context variables available, and their values, while testing
testing their alerting actions.
This commit is contained in:
Patrick Mueller 2020-12-15 16:31:36 -05:00 committed by GitHub
parent 6672e26fe5
commit f693697c18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 136 additions and 9 deletions

View file

@ -41,10 +41,12 @@ describe('mustache_renderer', () => {
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\\]');
if (escape === 'json') {
expect(renderMustacheString('{{f}}', variables, escape)).toBe('{\\"g\\":3,\\"h\\":null}');
} else if (escape === 'markdown') {
expect(renderMustacheString('{{f}}', variables, escape)).toBe('\\{"g":3,"h":null\\}');
} else {
expect(renderMustacheString('{{f}}', variables, escape)).toBe('[object Object]');
expect(renderMustacheString('{{f}}', variables, escape)).toBe('{"g":3,"h":null}');
}
expect(renderMustacheString('{{f.g}}', variables, escape)).toBe('3');
expect(renderMustacheString('{{f.h}}', variables, escape)).toBe('');
@ -180,4 +182,21 @@ describe('mustache_renderer', () => {
`);
});
});
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);
});
});

View file

@ -5,18 +5,19 @@
*/
import Mustache from 'mustache';
import { isString, cloneDeepWith } from 'lodash';
import { isString, isPlainObject, 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 augmentedVariables = augmentObjectVariables(variables);
const previousMustacheEscape = Mustache.escape;
Mustache.escape = getEscape(escape);
try {
return Mustache.render(`${string}`, variables);
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}`;
@ -27,11 +28,12 @@ export function renderMustacheString(string: string, variables: Variables, escap
// return a cloned object with all strings rendered as mustache templates
export function renderMustacheObject<Params>(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, variables, 'none');
return renderMustacheString(value, augmentedVariables, 'none');
});
// The return type signature for `cloneDeep()` ends up taking the return
@ -40,6 +42,36 @@ export function renderMustacheObject<Params>(params: Params, variables: Variable
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<string, unknown> {
if (object == null) return false;
if (typeof object !== 'object') return false;
if (!isPlainObject(object)) return false;
return true;
}
function getEscape(escape: Escape): (value: unknown) => string {
if (escape === 'markdown') return escapeMarkdown;
if (escape === 'slack') return escapeSlack;

View file

@ -24,6 +24,25 @@ export const EscapableStrings = {
escapableLineFeed: 'line\x0afeed',
};
export const DeepContextVariables = {
objectA: {
stringB: 'B',
arrayC: [
{ stringD: 'D1', numberE: 42 },
{ stringD: 'D2', numberE: 43 },
],
objectF: {
stringG: 'G',
nullG: null,
undefinedG: undefined,
},
},
stringH: 'H',
arrayI: [44, 45],
nullJ: null,
undefinedK: undefined,
};
function getAlwaysFiringAlertType() {
const paramsSchema = schema.object({
index: schema.string(),
@ -410,7 +429,10 @@ function getPatternFiringAlertType() {
for (const [instanceId, instancePattern] of Object.entries(pattern)) {
const scheduleByPattern = instancePattern[patternIndex];
if (scheduleByPattern === true) {
services.alertInstanceFactory(instanceId).scheduleActions('default', EscapableStrings);
services.alertInstanceFactory(instanceId).scheduleActions('default', {
...EscapableStrings,
deep: DeepContextVariables,
});
} else if (typeof scheduleByPattern === 'string') {
services
.alertInstanceFactory(instanceId)

View file

@ -79,7 +79,8 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
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
// from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts,
// const EscapableStrings
const varsTemplate = '{{context.escapableDoubleQuote}} -- {{context.escapableLineFeed}}';
const alertResponse = await supertest
@ -128,7 +129,8 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
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
// from x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts,
// const EscapableStrings
const varsTemplate =
'{{context.escapableBacktic}} -- {{context.escapableBold}} -- {{context.escapableBackticBold}} -- {{context.escapableHtml}}';
@ -162,6 +164,58 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
);
expect(body).to.be("back'tic -- `*bold*` -- `'*bold*'` -- &lt;&amp;&gt;");
});
it('should handle context variable object expansion', async () => {
const actionResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`)
.set('kbn-xsrf', 'test')
.send({
name: 'testing context variable expansion',
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 DeepContextVariables
const varsTemplate = '{{context.deep}}';
const alertResponse = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
.set('kbn-xsrf', 'foo')
.send(
getTestAlertData({
name: 'testing context variable expansion',
alertTypeId: 'test.patternFiring',
params: {
pattern: { instance: [true, 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(
'{"objectA":{"stringB":"B","arrayC":[{"stringD":"D1","numberE":42},{"stringD":"D2","numberE":43}],"objectF":{"stringG":"G","nullG":null}},"stringH":"H","arrayI":[44,45],"nullJ":null}'
);
});
});
async function waitForActionBody(url: string, id: string): Promise<string> {