Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[actions] expand object context variables as JSON #85903

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions x-pack/plugins/actions/server/lib/mustache_renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -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);
});
});
38 changes: 35 additions & 3 deletions x-pack/plugins/actions/server/lib/mustache_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;
Expand All @@ -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
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}}';

Expand Down Expand Up @@ -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> {
Expand Down