chore: reworking how the secure templater works a little bit

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2023-03-24 20:41:40 +01:00
parent 8a1e9dbdde
commit e3c38284bf
5 changed files with 141 additions and 63 deletions
@@ -69,7 +69,12 @@ describe('SecureTemplater', () => {
owner: 'my-owner',
host: 'my-host.com',
}));
const renderWith = await SecureTemplater.loadRenderer({ parseRepoUrl });
const projectSlug = jest.fn(() => 'my-owner/my-repo');
const renderWith = await SecureTemplater.loadRenderer({
templateFilters: { parseRepoUrl, projectSlug },
});
const renderWithout = await SecureTemplater.loadRenderer();
const ctx = {
@@ -95,7 +100,6 @@ describe('SecureTemplater', () => {
expect(parseRepoUrl.mock.calls).toEqual([
['https://my-host.com/my-owner/my-repo'],
['https://my-host.com/my-owner/my-repo'],
]);
});
@@ -104,7 +108,7 @@ describe('SecureTemplater', () => {
const mockFilter2 = jest.fn((var1, var2) => `${var1} ${var2}`);
const mockFilter3 = jest.fn((var1, var2) => ({ var1, var2 }));
const renderWith = await SecureTemplater.loadRenderer({
additionalTemplateFilters: { mockFilter1, mockFilter2, mockFilter3 },
templateFilters: { mockFilter1, mockFilter2, mockFilter3 },
});
const renderWithout = await SecureTemplater.loadRenderer();
@@ -149,7 +153,7 @@ describe('SecureTemplater', () => {
const mockGlobal2 = 'foo';
const mockGlobal3 = 123456;
const renderWith = await SecureTemplater.loadRenderer({
additionalTemplateGlobals: { mockGlobal1, mockGlobal2, mockGlobal3 },
templateGlobals: { mockGlobal1, mockGlobal2, mockGlobal3 },
});
const renderWithout = await SecureTemplater.loadRenderer();
@@ -168,11 +172,13 @@ describe('SecureTemplater', () => {
it('should not allow helpers to be rewritten', async () => {
const render = await SecureTemplater.loadRenderer({
parseRepoUrl: () => ({
repo: 'my-repo',
owner: 'my-owner',
host: 'my-host.com',
}),
templateFilters: {
parseRepoUrl: () => ({
repo: 'my-repo',
owner: 'my-owner',
host: 'my-host.com',
}),
},
});
const ctx = {
@@ -18,7 +18,6 @@ import { VM } from 'vm2';
import { resolvePackagePath } from '@backstage/backend-common';
import fs from 'fs-extra';
import { JsonValue } from '@backstage/types';
import { RepoSpec } from '../../scaffolder/actions/builtin/publish/util';
// language=JavaScript
const mkScript = (nunjucksSource: string) => `
@@ -46,26 +45,14 @@ const { render, renderCompat } = (() => {
});
compatEnv.addFilter('jsonify', compatEnv.getFilter('dump'));
if (typeof parseRepoUrl !== 'undefined') {
const safeHelperRef = parseRepoUrl;
env.addFilter('parseRepoUrl', repoUrl => {
return JSON.parse(safeHelperRef(repoUrl))
});
env.addFilter('projectSlug', repoUrl => {
const { owner, repo } = JSON.parse(safeHelperRef(repoUrl));
return owner + '/' + repo;
});
}
if (typeof additionalTemplateFilters !== 'undefined') {
for (const [filterName, filterFn] of Object.entries(additionalTemplateFilters)) {
if (typeof templateFilters !== 'undefined') {
for (const [filterName, filterFn] of Object.entries(templateFilters)) {
env.addFilter(filterName, (...args) => JSON.parse(filterFn(...args)));
}
}
if (typeof additionalTemplateGlobals !== 'undefined') {
for (const [globalName, global] of Object.entries(additionalTemplateGlobals)) {
if (typeof templateGlobals !== 'undefined') {
for (const [globalName, global] of Object.entries(templateGlobals)) {
if (typeof global === 'function') {
env.addGlobal(globalName, (...args) => JSON.parse(global(...args)));
} else {
@@ -114,16 +101,12 @@ export type TemplateGlobal =
| JsonValue;
export interface SecureTemplaterOptions {
/* Optional implementation of the parseRepoUrl filter */
parseRepoUrl?(repoUrl: string): RepoSpec;
/* Enables jinja compatibility and the "jsonify" filter */
cookiecutterCompat?: boolean;
/* Extra user-provided nunjucks filters */
additionalTemplateFilters?: Record<string, TemplateFilter>;
templateFilters?: Record<string, TemplateFilter>;
/* Extra user-provided nunjucks globals */
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
templateGlobals?: Record<string, TemplateGlobal>;
}
export type SecureTemplateRenderer = (
@@ -133,21 +116,12 @@ export type SecureTemplateRenderer = (
export class SecureTemplater {
static async loadRenderer(options: SecureTemplaterOptions = {}) {
const {
parseRepoUrl,
cookiecutterCompat,
additionalTemplateFilters,
additionalTemplateGlobals,
} = options;
const { cookiecutterCompat, templateFilters, templateGlobals } = options;
const sandbox: Record<string, any> = {};
if (parseRepoUrl) {
sandbox.parseRepoUrl = (url: string) => JSON.stringify(parseRepoUrl(url));
}
if (additionalTemplateFilters) {
sandbox.additionalTemplateFilters = Object.fromEntries(
Object.entries(additionalTemplateFilters)
if (templateFilters) {
sandbox.templateFilters = Object.fromEntries(
Object.entries(templateFilters)
.filter(([_, filterFunction]) => !!filterFunction)
.map(([filterName, filterFunction]) => [
filterName,
@@ -155,9 +129,9 @@ export class SecureTemplater {
]),
);
}
if (additionalTemplateGlobals) {
sandbox.additionalTemplateGlobals = Object.fromEntries(
Object.entries(additionalTemplateGlobals)
if (templateGlobals) {
sandbox.templateGlobals = Object.fromEntries(
Object.entries(templateGlobals)
.filter(([_, global]) => !!global)
.map(([globalName, global]) => {
if (typeof global === 'function') {
@@ -0,0 +1,48 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { parseEntityRef } from '@backstage/catalog-model';
import { ScmIntegrations } from '@backstage/integration';
import { JsonValue } from '@backstage/types';
import { TemplateFilter } from '..';
import { parseRepoUrl } from '../../scaffolder/actions/builtin/publish/util';
export const createDefaultFilters = ({
integrations,
}: {
integrations: ScmIntegrations;
}): Record<string, TemplateFilter> => {
return {
parseRepoUrl: url => parseRepoUrl(url as string, integrations),
parseEntityRef: ref => parseEntityRef(ref as string),
pick: (obj: JsonValue, key: JsonValue) => {
if (
typeof obj === 'object' &&
!Array.isArray(obj) &&
typeof key === 'string'
) {
return obj?.[key];
}
throw new Error(
`Invalid arguments to pick filter, expected object and string, got ${typeof obj} and ${typeof key}`,
);
},
projectSlug: repoUrl => {
const { owner, repo } = parseRepoUrl(repoUrl as string, integrations);
return `${owner}/${repo}`;
},
};
};
@@ -698,6 +698,58 @@ describe('DefaultWorkflowRunner', () => {
repo: 'repo',
});
});
it('provides the parseEntityRef filter', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
name: 'name',
action: 'output-action',
input: {},
},
],
output: {
foo: '${{ parameters.entity | parseEntityRef }}',
},
parameters: {
entity: 'component:default/ben',
},
});
const { output } = await runner.execute(task);
expect(output.foo).toEqual({
kind: 'component',
namespace: 'default',
name: 'ben',
});
});
it('provides the pick filter', async () => {
const task = createMockTaskWithSpec({
apiVersion: 'scaffolder.backstage.io/v1beta3',
steps: [
{
id: 'test',
name: 'name',
action: 'output-action',
input: {},
},
],
output: {
foo: '${{ parameters.entity | parseEntityRef | pick("kind") }}',
},
parameters: {
entity: 'component:default/ben',
},
});
const { output } = await runner.execute(task);
expect(output.foo).toEqual('component');
});
});
describe('dry run', () => {
@@ -30,7 +30,6 @@ import { InputError } from '@backstage/errors';
import { PassThrough } from 'stream';
import { generateExampleOutput, isTruthy } from './helper';
import { validate as validateJsonSchema } from 'jsonschema';
import { parseRepoUrl } from '../actions/builtin/publish/util';
import { TemplateActionRegistry } from '../actions';
import {
TemplateFilter,
@@ -46,6 +45,7 @@ import {
import { TemplateAction } from '@backstage/plugin-scaffolder-node';
import { UserEntity } from '@backstage/catalog-model';
import { createCounterMetric, createHistogramMetric } from '../../util/metrics';
import { createDefaultFilters } from '../../lib/templating/filters';
type NunjucksWorkflowRunnerOptions = {
workingDirectory: string;
@@ -103,7 +103,12 @@ const createStepLogger = ({
};
export class NunjucksWorkflowRunner implements WorkflowRunner {
constructor(private readonly options: NunjucksWorkflowRunnerOptions) {}
private readonly defaultTemplateFilters: Record<string, TemplateFilter>;
constructor(private readonly options: NunjucksWorkflowRunnerOptions) {
this.defaultTemplateFilters = createDefaultFilters({
integrations: this.options.integrations,
});
}
private readonly tracker = scaffoldingTracker();
@@ -329,22 +334,15 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
await task.getWorkspaceName(),
);
const {
additionalTemplateFilters,
additionalTemplateGlobals,
integrations,
} = this.options;
const { additionalTemplateFilters, additionalTemplateGlobals } =
this.options;
const renderTemplate = await SecureTemplater.loadRenderer({
// TODO(blam): let's work out how we can deprecate this.
// We shouldn't really need to be exposing these now we can deal with
// objects in the params block.
// Maybe we can expose a new RepoUrlPicker with secrets for V3 that provides an object already.
parseRepoUrl(url: string) {
return parseRepoUrl(url, integrations);
templateFilters: {
...this.defaultTemplateFilters,
...additionalTemplateFilters,
},
additionalTemplateFilters,
additionalTemplateGlobals,
templateGlobals: additionalTemplateGlobals,
});
try {