Merge pull request #8235 from backstage/mob/secure-templating
scaffolder-backend: sandbox v1beta3 template execution
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Switched to executing scaffolder templating in a secure context for any template based on nunjucks, as it is [not secure by default](https://mozilla.github.io/nunjucks/api.html#user-defined-templates-warning).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,8 @@
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
"clean": "backstage-cli clean",
|
||||
"build:assets": "node scripts/build-nunjucks.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.9.10",
|
||||
@@ -68,7 +69,8 @@
|
||||
"octokit-plugin-create-pull-request": "^3.10.0",
|
||||
"uuid": "^8.2.0",
|
||||
"winston": "^3.2.1",
|
||||
"yaml": "^1.10.0"
|
||||
"yaml": "^1.10.0",
|
||||
"vm2": "^3.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.9.0",
|
||||
@@ -79,6 +81,7 @@
|
||||
"@types/mock-fs": "^4.13.0",
|
||||
"@types/nunjucks": "^3.1.4",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"esbuild": "^0.13.14",
|
||||
"jest-when": "^3.1.0",
|
||||
"mock-fs": "^5.1.0",
|
||||
"msw": "^0.35.0",
|
||||
@@ -88,7 +91,8 @@
|
||||
"files": [
|
||||
"dist",
|
||||
"migrations",
|
||||
"config.d.ts"
|
||||
"config.d.ts",
|
||||
"assets"
|
||||
],
|
||||
"configSchema": "config.d.ts"
|
||||
}
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Copyright 2021 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
|
||||
const path = require('path');
|
||||
|
||||
const NUNJUCKS_LICENSE = `/**
|
||||
* Copyright (c) 2012-2015, James Long
|
||||
* All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions are
|
||||
* met:
|
||||
*
|
||||
* Redistributions of source code must retain the above copyright
|
||||
* notice, this list of conditions and the following disclaimer.
|
||||
*
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following disclaimer in
|
||||
* the documentation and/or other materials provided with the
|
||||
* distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
* HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
*/
|
||||
`;
|
||||
|
||||
// This script is used to bundle nunjucks into a single script that is
|
||||
// loaded into a sandbox for executing templates.
|
||||
require('esbuild')
|
||||
.build({
|
||||
entryPoints: [require.resolve('nunjucks')],
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node14',
|
||||
banner: { js: NUNJUCKS_LICENSE },
|
||||
external: ['fsevents'],
|
||||
outfile: path.resolve(__dirname, '../assets/nunjucks.js.txt'),
|
||||
})
|
||||
.catch(err => {
|
||||
console.log(err.stack);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2021 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 { SecureTemplater } from './SecureTemplater';
|
||||
|
||||
describe('SecureTemplater', () => {
|
||||
it('should render some templates', async () => {
|
||||
const render = await SecureTemplater.loadRenderer();
|
||||
expect(render('${{ test }}', { test: 'my-value' })).toBe('my-value');
|
||||
|
||||
expect(render('${{ test | dump }}', { test: 'my-value' })).toBe(
|
||||
'"my-value"',
|
||||
);
|
||||
|
||||
expect(
|
||||
render('${{ test | replace("my-", "our-") }}', {
|
||||
test: 'my-value',
|
||||
}),
|
||||
).toBe('our-value');
|
||||
|
||||
expect(() =>
|
||||
render('${{ invalid...syntax }}', {
|
||||
test: 'my-value',
|
||||
}),
|
||||
).toThrow(/expected name as lookup value, got ./);
|
||||
});
|
||||
|
||||
it('should make cookiecutter compatibility available when requested', async () => {
|
||||
const renderWith = await SecureTemplater.loadRenderer({
|
||||
cookiecutterCompat: true,
|
||||
});
|
||||
const renderWithout = await SecureTemplater.loadRenderer();
|
||||
|
||||
// Same two tests repeated to make sure switching back and forth works
|
||||
expect(renderWith('{{ 1 | jsonify }}', {})).toBe('1');
|
||||
expect(renderWith('{{ 1 | jsonify }}', {})).toBe('1');
|
||||
expect(() => renderWithout('${{ 1 | jsonify }}', {})).toThrow(
|
||||
/Error: filter not found: jsonify/,
|
||||
);
|
||||
expect(renderWith('{{ 1 | jsonify }}', {})).toBe('1');
|
||||
expect(() => renderWithout('${{ 1 | jsonify }}', {})).toThrow(
|
||||
/Error: filter not found: jsonify/,
|
||||
);
|
||||
expect(() => renderWithout('${{ 1 | jsonify }}', {})).toThrow(
|
||||
/Error: filter not found: jsonify/,
|
||||
);
|
||||
expect(() => renderWithout('${{ 1 | jsonify }}', {})).toThrow(
|
||||
/Error: filter not found: jsonify/,
|
||||
);
|
||||
expect(renderWith('{{ 1 | jsonify }}', {})).toBe('1');
|
||||
});
|
||||
|
||||
it('should make parseRepoUrl available when requested', async () => {
|
||||
const parseRepoUrl = jest.fn(() => ({
|
||||
repo: 'my-repo',
|
||||
owner: 'my-owner',
|
||||
host: 'my-host.com',
|
||||
}));
|
||||
const renderWith = await SecureTemplater.loadRenderer({ parseRepoUrl });
|
||||
const renderWithout = await SecureTemplater.loadRenderer();
|
||||
|
||||
const ctx = {
|
||||
repoUrl: 'https://my-host.com/my-owner/my-repo',
|
||||
};
|
||||
|
||||
expect(renderWith('${{ repoUrl | parseRepoUrl | dump }}', ctx)).toBe(
|
||||
JSON.stringify({
|
||||
repo: 'my-repo',
|
||||
owner: 'my-owner',
|
||||
host: 'my-host.com',
|
||||
}),
|
||||
);
|
||||
expect(renderWith('${{ repoUrl | projectSlug }}', ctx)).toBe(
|
||||
'my-owner/my-repo',
|
||||
);
|
||||
expect(() =>
|
||||
renderWithout('${{ repoUrl | parseRepoUrl | dump }}', ctx),
|
||||
).toThrow(/Error: filter not found: parseRepoUrl/);
|
||||
expect(() => renderWithout('${{ repoUrl | projectSlug }}', ctx)).toThrow(
|
||||
/Error: filter not found: projectSlug/,
|
||||
);
|
||||
|
||||
expect(parseRepoUrl.mock.calls).toEqual([
|
||||
['https://my-host.com/my-owner/my-repo'],
|
||||
['https://my-host.com/my-owner/my-repo'],
|
||||
]);
|
||||
});
|
||||
|
||||
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',
|
||||
}),
|
||||
});
|
||||
|
||||
const ctx = {
|
||||
repoUrl: 'https://my-host.com/my-owner/my-repo',
|
||||
};
|
||||
expect(
|
||||
render(
|
||||
'${{ ({}).constructor.constructor("parseRepoUrl = () => JSON.stringify(`inject`)")() }}',
|
||||
ctx,
|
||||
),
|
||||
).toBe('');
|
||||
|
||||
expect(render('${{ repoUrl | parseRepoUrl | dump }}', ctx)).toBe(
|
||||
JSON.stringify({
|
||||
repo: 'my-repo',
|
||||
owner: 'my-owner',
|
||||
host: 'my-host.com',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows pollution during a single template execution', async () => {
|
||||
const render = await SecureTemplater.loadRenderer();
|
||||
|
||||
const ctx = {
|
||||
x: 'foo',
|
||||
};
|
||||
expect(render('${{ x }}', ctx)).toBe('foo');
|
||||
expect(
|
||||
render(
|
||||
'${{ ({}).constructor.constructor("Array.prototype.forEach = () => {}")() }}',
|
||||
ctx,
|
||||
),
|
||||
).toBe('');
|
||||
expect(() => render('${{ x }}', ctx)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2021 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 { VM } from 'vm2';
|
||||
import { resolvePackagePath } from '@backstage/backend-common';
|
||||
import fs from 'fs-extra';
|
||||
import { RepoSpec } from '../../scaffolder/actions/builtin/publish/util';
|
||||
|
||||
const mkScript = (nunjucksSource: string) => `
|
||||
const { render, renderCompat } = (() => {
|
||||
const module = {};
|
||||
const process = { env: {} };
|
||||
const require = (pkg) => { if (pkg === 'events') { return function (){}; }};
|
||||
|
||||
${nunjucksSource}
|
||||
|
||||
const env = module.exports.configure({
|
||||
autoescape: false,
|
||||
tags: {
|
||||
variableStart: '\${{',
|
||||
variableEnd: '}}',
|
||||
},
|
||||
});
|
||||
|
||||
const compatEnv = module.exports.configure({
|
||||
autoescape: false,
|
||||
tags: {
|
||||
variableStart: '{{',
|
||||
variableEnd: '}}',
|
||||
},
|
||||
});
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
let uninstallCompat = undefined;
|
||||
|
||||
function render(str, values) {
|
||||
try {
|
||||
if (uninstallCompat) {
|
||||
uninstallCompat();
|
||||
uninstallCompat = undefined;
|
||||
}
|
||||
return env.renderString(str, JSON.parse(values));
|
||||
} catch (error) {
|
||||
// Make sure errors don't leak anything
|
||||
throw new Error(String(error.message));
|
||||
}
|
||||
}
|
||||
|
||||
function renderCompat(str, values) {
|
||||
try {
|
||||
if (!uninstallCompat) {
|
||||
uninstallCompat = module.exports.installJinjaCompat();
|
||||
}
|
||||
return compatEnv.renderString(str, JSON.parse(values));
|
||||
} catch (error) {
|
||||
// Make sure errors don't leak anything
|
||||
throw new Error(String(error.message));
|
||||
}
|
||||
}
|
||||
|
||||
return { render, renderCompat };
|
||||
})();
|
||||
`;
|
||||
|
||||
export interface SecureTemplaterOptions {
|
||||
/* Optional implementation of the parseRepoUrl filter */
|
||||
parseRepoUrl?(repoUrl: string): RepoSpec;
|
||||
|
||||
/* Enables jinja compatibility and the "jsonify" filter */
|
||||
cookiecutterCompat?: boolean;
|
||||
}
|
||||
|
||||
export type SecureTemplateRenderer = (
|
||||
template: string,
|
||||
values: unknown,
|
||||
) => string;
|
||||
|
||||
export class SecureTemplater {
|
||||
static async loadRenderer(options: SecureTemplaterOptions = {}) {
|
||||
const { parseRepoUrl, cookiecutterCompat } = options;
|
||||
let sandbox = undefined;
|
||||
|
||||
if (parseRepoUrl) {
|
||||
sandbox = {
|
||||
parseRepoUrl: (url: string) => JSON.stringify(parseRepoUrl(url)),
|
||||
};
|
||||
}
|
||||
|
||||
const vm = new VM({ sandbox });
|
||||
|
||||
const nunjucksSource = await fs.readFile(
|
||||
resolvePackagePath(
|
||||
'@backstage/plugin-scaffolder-backend',
|
||||
'assets/nunjucks.js.txt',
|
||||
),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
vm.run(mkScript(nunjucksSource));
|
||||
|
||||
const render: SecureTemplateRenderer = (template, values) => {
|
||||
if (!vm) {
|
||||
throw new Error('SecureTemplater has not been initialized');
|
||||
}
|
||||
vm.setGlobal('templateStr', template);
|
||||
vm.setGlobal('templateValues', JSON.stringify(values));
|
||||
|
||||
if (cookiecutterCompat) {
|
||||
return vm.run(`renderCompat(templateStr, templateValues)`);
|
||||
}
|
||||
|
||||
return vm.run(`render(templateStr, templateValues)`);
|
||||
};
|
||||
return render;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,17 @@ jest.mock('./helpers', () => ({
|
||||
fetchContents: jest.fn(),
|
||||
}));
|
||||
|
||||
const realFiles = Object.fromEntries(
|
||||
[
|
||||
require.resolve('vm2/lib/fixasync'),
|
||||
resolvePackagePath(
|
||||
'@backstage/plugin-scaffolder-backend',
|
||||
'assets',
|
||||
'nunjucks.js.txt',
|
||||
),
|
||||
].map(k => [k, mockFs.load(k)]),
|
||||
);
|
||||
|
||||
const aBinaryFile = fs.readFileSync(
|
||||
resolvePackagePath(
|
||||
'@backstage/plugin-scaffolder-backend',
|
||||
@@ -76,7 +87,9 @@ describe('fetch:template', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs();
|
||||
mockFs({
|
||||
...realFiles,
|
||||
});
|
||||
|
||||
action = createFetchTemplateAction({
|
||||
reader: Symbol('UrlReader') as unknown as UrlReader,
|
||||
@@ -150,6 +163,7 @@ describe('fetch:template', () => {
|
||||
|
||||
mockFetchContents.mockImplementation(({ outputPath }) => {
|
||||
mockFs({
|
||||
...realFiles,
|
||||
[outputPath]: {
|
||||
'an-executable.sh': mockFs.file({
|
||||
content: '#!/usr/bin/env bash',
|
||||
@@ -259,6 +273,7 @@ describe('fetch:template', () => {
|
||||
|
||||
mockFetchContents.mockImplementation(({ outputPath }) => {
|
||||
mockFs({
|
||||
...realFiles,
|
||||
[outputPath]: {
|
||||
processed: {
|
||||
'templated-content-${{ values.name }}.txt':
|
||||
@@ -312,6 +327,7 @@ describe('fetch:template', () => {
|
||||
|
||||
mockFetchContents.mockImplementation(({ outputPath }) => {
|
||||
mockFs({
|
||||
...realFiles,
|
||||
[outputPath]: {
|
||||
'{{ cookiecutter.name }}.txt': 'static content',
|
||||
subdir: {
|
||||
@@ -366,6 +382,7 @@ describe('fetch:template', () => {
|
||||
|
||||
mockFetchContents.mockImplementation(({ outputPath }) => {
|
||||
mockFs({
|
||||
...realFiles,
|
||||
[outputPath]: {
|
||||
'empty-dir-${{ values.count }}': {},
|
||||
'static.txt': 'static content',
|
||||
@@ -447,6 +464,7 @@ describe('fetch:template', () => {
|
||||
|
||||
mockFetchContents.mockImplementation(({ outputPath }) => {
|
||||
mockFs({
|
||||
...realFiles,
|
||||
[outputPath]: {
|
||||
'${{ values.name }}.njk': '${{ values.name }}: ${{ values.count }}',
|
||||
'${{ values.name }}.txt.jinja2':
|
||||
|
||||
@@ -21,22 +21,9 @@ import { ScmIntegrations } from '@backstage/integration';
|
||||
import { fetchContents } from './helpers';
|
||||
import { createTemplateAction } from '../../createTemplateAction';
|
||||
import globby from 'globby';
|
||||
import nunjucks from 'nunjucks';
|
||||
import fs from 'fs-extra';
|
||||
import { isBinaryFile } from 'isbinaryfile';
|
||||
|
||||
/*
|
||||
* Maximise compatibility with Jinja (and therefore cookiecutter)
|
||||
* using nunjucks jinja compat mode. Since this method mutates
|
||||
* the global nunjucks instance, we can't enable this per-template,
|
||||
* or only for templates with cookiecutter compat enabled, so the
|
||||
* next best option is to explicitly enable it globally and allow
|
||||
* folks to rely on jinja compatibility behaviour in fetch:template
|
||||
* templates if they wish.
|
||||
*
|
||||
* cf. https://mozilla.github.io/nunjucks/api.html#installjinjacompat
|
||||
*/
|
||||
nunjucks.installJinjaCompat();
|
||||
import { SecureTemplater } from '../../../../lib/templating/SecureTemplater';
|
||||
|
||||
type CookieCompatInput = {
|
||||
copyWithoutRender?: string[];
|
||||
@@ -179,36 +166,6 @@ export function createFetchTemplateAction(options: {
|
||||
).flat(),
|
||||
);
|
||||
|
||||
// Create a templater
|
||||
const templater = nunjucks.configure({
|
||||
...(ctx.input.cookiecutterCompat
|
||||
? {}
|
||||
: {
|
||||
tags: {
|
||||
// TODO(mtlewis/orkohunter): Document Why we are changing the literals? Not here, but on scaffolder docs. ADR?
|
||||
variableStart: '${{',
|
||||
variableEnd: '}}',
|
||||
},
|
||||
}),
|
||||
// We don't want this builtin auto-escaping, since uses HTML escape sequences
|
||||
// like `"` - the correct way to escape strings in our case depends on
|
||||
// the file type.
|
||||
autoescape: false,
|
||||
});
|
||||
|
||||
if (ctx.input.cookiecutterCompat) {
|
||||
// The "jsonify" filter built into cookiecutter is common
|
||||
// in fetch:cookiecutter templates, so when compat mode
|
||||
// is enabled we alias the "dump" filter from nunjucks as
|
||||
// jsonify. Dump accepts an optional `spaces` parameter
|
||||
// which enables indented output, but when this parameter
|
||||
// is not supplied it works identically to jsonify.
|
||||
//
|
||||
// cf. https://cookiecutter.readthedocs.io/en/latest/advanced/template_extensions.html?highlight=jsonify#jsonify-extension
|
||||
// cf. https://mozilla.github.io/nunjucks/templating.html#dump
|
||||
templater.addFilter('jsonify', templater.getFilter('dump'));
|
||||
}
|
||||
|
||||
// Cookiecutter prefixes all parameters in templates with
|
||||
// `cookiecutter.`. To replicate this, we wrap our parameters
|
||||
// in an object with a `cookiecutter` property when compat
|
||||
@@ -223,6 +180,10 @@ export function createFetchTemplateAction(options: {
|
||||
ctx.input.values,
|
||||
);
|
||||
|
||||
const renderTemplate = await SecureTemplater.loadRenderer({
|
||||
cookiecutterCompat: ctx.input.cookiecutterCompat,
|
||||
});
|
||||
|
||||
for (const location of allEntriesInTemplate) {
|
||||
let renderFilename: boolean;
|
||||
let renderContents: boolean;
|
||||
@@ -238,7 +199,7 @@ export function createFetchTemplateAction(options: {
|
||||
renderFilename = renderContents = !nonTemplatedEntries.has(location);
|
||||
}
|
||||
if (renderFilename) {
|
||||
localOutputPath = templater.renderString(localOutputPath, context);
|
||||
localOutputPath = renderTemplate(localOutputPath, context);
|
||||
}
|
||||
const outputPath = resolveSafeChildPath(outputDir, localOutputPath);
|
||||
// variables have been expanded to make an empty file name
|
||||
@@ -275,7 +236,7 @@ export function createFetchTemplateAction(options: {
|
||||
await fs.outputFile(
|
||||
outputPath,
|
||||
renderContents
|
||||
? templater.renderString(inputFileContents, context)
|
||||
? renderTemplate(inputFileContents, context)
|
||||
: inputFileContents,
|
||||
{ mode: statsObj.mode },
|
||||
);
|
||||
|
||||
@@ -17,13 +17,24 @@
|
||||
import mockFs from 'mock-fs';
|
||||
import * as winston from 'winston';
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common';
|
||||
import { NunjucksWorkflowRunner } from './NunjucksWorkflowRunner';
|
||||
import { TemplateActionRegistry } from '../actions';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { TaskContext, TaskSpec } from './types';
|
||||
|
||||
const realFiles = Object.fromEntries(
|
||||
[
|
||||
require.resolve('vm2/lib/fixasync'),
|
||||
resolvePackagePath(
|
||||
'@backstage/plugin-scaffolder-backend',
|
||||
'assets',
|
||||
'nunjucks.js.txt',
|
||||
),
|
||||
].map(k => [k, mockFs.load(k)]),
|
||||
);
|
||||
|
||||
describe('DefaultWorkflowRunner', () => {
|
||||
const logger = getVoidLogger();
|
||||
let actionRegistry = new TemplateActionRegistry();
|
||||
@@ -50,6 +61,7 @@ describe('DefaultWorkflowRunner', () => {
|
||||
winston.format.simple(); // put logform the require cache before mocking fs
|
||||
mockFs({
|
||||
'/tmp': mockFs.directory(),
|
||||
...realFiles,
|
||||
});
|
||||
|
||||
jest.resetAllMocks();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
import {
|
||||
TaskContext,
|
||||
@@ -23,9 +24,9 @@ import {
|
||||
WorkflowRunner,
|
||||
} from './types';
|
||||
import * as winston from 'winston';
|
||||
import nunjucks from 'nunjucks';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import nunjucks from 'nunjucks';
|
||||
import { JsonObject, JsonValue } from '@backstage/types';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { PassThrough } from 'stream';
|
||||
@@ -33,6 +34,10 @@ import { isTruthy } from './helper';
|
||||
import { validate as validateJsonSchema } from 'jsonschema';
|
||||
import { parseRepoUrl } from '../actions/builtin/publish/util';
|
||||
import { TemplateActionRegistry } from '../actions';
|
||||
import {
|
||||
SecureTemplater,
|
||||
SecureTemplateRenderer,
|
||||
} from '../../lib/templating/SecureTemplater';
|
||||
|
||||
type NunjucksWorkflowRunnerOptions = {
|
||||
workingDirectory: string;
|
||||
@@ -84,36 +89,31 @@ const createStepLogger = ({
|
||||
};
|
||||
|
||||
export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
private readonly nunjucks: nunjucks.Environment;
|
||||
|
||||
private readonly nunjucksOptions: nunjucks.ConfigureOptions = {
|
||||
autoescape: false,
|
||||
tags: {
|
||||
variableStart: '${{',
|
||||
variableEnd: '}}',
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private readonly options: NunjucksWorkflowRunnerOptions) {
|
||||
this.nunjucks = nunjucks.configure(this.nunjucksOptions);
|
||||
|
||||
// TODO(blam): let's work out how we can deprecate these.
|
||||
// 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.
|
||||
this.nunjucks.addFilter('parseRepoUrl', repoUrl => {
|
||||
return parseRepoUrl(repoUrl, this.options.integrations);
|
||||
});
|
||||
|
||||
this.nunjucks.addFilter('projectSlug', repoUrl => {
|
||||
const { owner, repo } = parseRepoUrl(repoUrl, this.options.integrations);
|
||||
return `${owner}/${repo}`;
|
||||
});
|
||||
}
|
||||
constructor(private readonly options: NunjucksWorkflowRunnerOptions) {}
|
||||
|
||||
private isSingleTemplateString(input: string) {
|
||||
const { parser, nodes } = require('nunjucks');
|
||||
const parsed = parser.parse(input, {}, this.nunjucksOptions);
|
||||
const { parser, nodes } = nunjucks as unknown as {
|
||||
parser: {
|
||||
parse(
|
||||
template: string,
|
||||
ctx: object,
|
||||
options: nunjucks.ConfigureOptions,
|
||||
): { children: { children?: unknown[] }[] };
|
||||
};
|
||||
nodes: { TemplateData: Function };
|
||||
};
|
||||
|
||||
const parsed = parser.parse(
|
||||
input,
|
||||
{},
|
||||
{
|
||||
autoescape: false,
|
||||
tags: {
|
||||
variableStart: '${{',
|
||||
variableEnd: '}}',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
parsed.children.length === 1 &&
|
||||
@@ -121,7 +121,11 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
);
|
||||
}
|
||||
|
||||
private render<T>(input: T, context: TemplateContext): T {
|
||||
private render<T>(
|
||||
input: T,
|
||||
context: TemplateContext,
|
||||
renderTemplate: SecureTemplateRenderer,
|
||||
): T {
|
||||
return JSON.parse(JSON.stringify(input), (_key, value) => {
|
||||
try {
|
||||
if (typeof value === 'string') {
|
||||
@@ -134,10 +138,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
);
|
||||
|
||||
// Run the templating
|
||||
const templated = this.nunjucks.renderString(
|
||||
wrappedDumped,
|
||||
context,
|
||||
);
|
||||
const templated = renderTemplate(wrappedDumped, context);
|
||||
|
||||
// If there's an empty string returned, then it's undefined
|
||||
if (templated === '') {
|
||||
@@ -154,7 +155,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
}
|
||||
|
||||
// Fallback to default behaviour
|
||||
const templated = this.nunjucks.renderString(value, context);
|
||||
const templated = renderTemplate(value, context);
|
||||
|
||||
if (templated === '') {
|
||||
return undefined;
|
||||
@@ -179,6 +180,18 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
this.options.workingDirectory,
|
||||
await task.getWorkspaceName(),
|
||||
);
|
||||
|
||||
const { integrations } = 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);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await fs.ensureDir(workspacePath);
|
||||
await task.emitLog(
|
||||
@@ -193,7 +206,11 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
for (const step of task.spec.steps) {
|
||||
try {
|
||||
if (step.if) {
|
||||
const ifResult = await this.render(step.if, context);
|
||||
const ifResult = await this.render(
|
||||
step.if,
|
||||
context,
|
||||
renderTemplate,
|
||||
);
|
||||
if (!isTruthy(ifResult)) {
|
||||
await task.emitLog(
|
||||
`Skipping step ${step.id} because it's if condition was false`,
|
||||
@@ -211,7 +228,9 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
const action = this.options.actionRegistry.get(step.action);
|
||||
const { taskLogger, streamLogger } = createStepLogger({ task, step });
|
||||
|
||||
const input = (step.input && this.render(step.input, context)) ?? {};
|
||||
const input =
|
||||
(step.input && this.render(step.input, context, renderTemplate)) ??
|
||||
{};
|
||||
|
||||
if (action.schema?.input) {
|
||||
const validateResult = validateJsonSchema(
|
||||
@@ -274,7 +293,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
|
||||
}
|
||||
}
|
||||
|
||||
const output = this.render(task.spec.output, context);
|
||||
const output = this.render(task.spec.output, context, renderTemplate);
|
||||
|
||||
return { output };
|
||||
} finally {
|
||||
|
||||
@@ -13699,6 +13699,114 @@ es6-weak-map@^2.0.3:
|
||||
es6-iterator "^2.0.3"
|
||||
es6-symbol "^3.1.1"
|
||||
|
||||
esbuild-android-arm64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.13.14.tgz#c85083ece26be3d67e6c720e088968a98409e023"
|
||||
integrity sha512-Q+Xhfp827r+ma8/DJgpMRUbDZfefsk13oePFEXEIJ4gxFbNv5+vyiYXYuKm43/+++EJXpnaYmEnu4hAKbAWYbA==
|
||||
|
||||
esbuild-darwin-64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.13.14.tgz#8e4e237ad847cc54a1d3a5caee26a746b9f0b81f"
|
||||
integrity sha512-YmOhRns6QBNSjpVdTahi/yZ8dscx9ai7a6OY6z5ACgOuQuaQ2Qk2qgJ0/siZ6LgD0gJFMV8UINFV5oky5TFNQQ==
|
||||
|
||||
esbuild-darwin-arm64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.14.tgz#b3b5ebd40b2cb06ee0f6fb342dd4bdcca54ad273"
|
||||
integrity sha512-Lp00VTli2jqZghSa68fx3fEFCPsO1hK59RMo1PRap5RUjhf55OmaZTZYnCDI0FVlCtt+gBwX5qwFt4lc6tI1xg==
|
||||
|
||||
esbuild-freebsd-64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.14.tgz#175ecb2fa8141428cf70ea2d5f4c27534bad53e0"
|
||||
integrity sha512-BKosI3jtvTfnmsCW37B1TyxMUjkRWKqopR0CE9AF2ratdpkxdR24Vpe3gLKNyWiZ7BE96/SO5/YfhbPUzY8wKw==
|
||||
|
||||
esbuild-freebsd-arm64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.14.tgz#a7d64e41d1fa581f8db7775e5200f18e67d70c4d"
|
||||
integrity sha512-yd2uh0yf+fWv5114+SYTl4/1oDWtr4nN5Op+PGxAkMqHfYfLjFKpcxwCo/QOS/0NWqPVE8O41IYZlFhbEN2B8Q==
|
||||
|
||||
esbuild-linux-32@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.13.14.tgz#14bdd4f6b6cfd35c65c835894651ba335c2117da"
|
||||
integrity sha512-a8rOnS1oWSfkkYWXoD2yXNV4BdbDKA7PNVQ1klqkY9SoSApL7io66w5H44mTLsfyw7G6Z2vLlaLI2nz9MMAowA==
|
||||
|
||||
esbuild-linux-64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.14.tgz#7fd56851b2982fdd0cd8447ee9858c2c5711708a"
|
||||
integrity sha512-yPZSoMs9W2MC3Dw+6kflKt5FfQm6Dicex9dGIr1OlHRsn3Hm7yGMUTctlkW53KknnZdOdcdd5upxvbxqymczVQ==
|
||||
|
||||
esbuild-linux-arm64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.14.tgz#a55634d70679ba509adeafd68eebb9fd1ec5af6c"
|
||||
integrity sha512-Lvo391ln9PzC334e+jJ2S0Rt0cxP47eoH5gFyv/E8HhOnEJTvm7A+RRnMjjHnejELacTTfYgFGQYPjLsi/jObQ==
|
||||
|
||||
esbuild-linux-arm@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.13.14.tgz#bb96a99677e608b31ff61f37564326d38e846ca2"
|
||||
integrity sha512-8chZE4pkKRvJ/M/iwsNQ1KqsRg2RyU5eT/x2flNt/f8F2TVrDreR7I0HEeCR50wLla3B1C3wTIOzQBmjuc6uWg==
|
||||
|
||||
esbuild-linux-mips64le@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.14.tgz#6a55362a8fd1e593dea2ecc41877beed8b8184b9"
|
||||
integrity sha512-MZhgxbmrWbpY3TOE029O6l5tokG9+Yoj2hW7vdit/d/VnmneqeGrSHADuDL6qXM8L5jaCiaivb4VhsyVCpdAbQ==
|
||||
|
||||
esbuild-linux-ppc64le@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.14.tgz#9e0048587ece0a7f184ab147f20d077098045e7f"
|
||||
integrity sha512-un7KMwS7fX1Un6BjfSZxTT8L5cV/8Uf4SAhM7WYy2XF8o8TI+uRxxD03svZnRNIPsN2J5cl6qV4n7Iwz+yhhVw==
|
||||
|
||||
esbuild-netbsd-64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.14.tgz#dcab16a4bbcfa16e2e8535dadc5f64fdc891c63b"
|
||||
integrity sha512-5ekKx/YbOmmlTeNxBjh38Uh5TGn5C4uyqN17i67k18pS3J+U2hTVD7rCxcFcRS1AjNWumkVL3jWqYXadFwMS0Q==
|
||||
|
||||
esbuild-openbsd-64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.14.tgz#3c7453b155ebb68dc34d5aec3bd6505337bdda08"
|
||||
integrity sha512-9bzvwewHjct2Cv5XcVoE1yW5YTW12Sk838EYfA46abgnhxGoFSD1mFcaztp5HHC43AsF+hQxbSFG/RilONARUA==
|
||||
|
||||
esbuild-sunos-64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.13.14.tgz#85addf5fef6b5db154a955d4f2e88953359d75ce"
|
||||
integrity sha512-mjMrZB76M6FmoiTvj/RGWilrioR7gVwtFBRVugr9qLarXMIU1W/pQx+ieEOtflrW61xo8w1fcxyHsVVGRvoQ0w==
|
||||
|
||||
esbuild-windows-32@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.13.14.tgz#f77f98f30a5c636c44db2428ecdf9bcbbaedb1a7"
|
||||
integrity sha512-GZa6mrx2rgfbH/5uHg0Rdw50TuOKbdoKCpEBitzmG5tsXBdce+cOL+iFO5joZc6fDVCLW3Y6tjxmSXRk/v20Hg==
|
||||
|
||||
esbuild-windows-64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.13.14.tgz#bc778674c40d65150d12385e0f23eb3a0badbd0d"
|
||||
integrity sha512-Lsgqah24bT7ClHjLp/Pj3A9wxjhIAJyWQcrOV4jqXAFikmrp2CspA8IkJgw7HFjx6QrJuhpcKVbCAe/xw0i2yw==
|
||||
|
||||
esbuild-windows-arm64@0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.14.tgz#91a8dad35ab2c4dd27cd83860742955b25a354d7"
|
||||
integrity sha512-KP8FHVlWGhM7nzYtURsGnskXb/cBCPTfj0gOKfjKq2tHtYnhDZywsUG57nk7TKhhK0fL11LcejHG3LRW9RF/9A==
|
||||
|
||||
esbuild@^0.13.14:
|
||||
version "0.13.14"
|
||||
resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.13.14.tgz#98a3f7f42809abdc2b57c84565d0f713382dc1a5"
|
||||
integrity sha512-xu4D+1ji9x53ocuomcY+KOrwAnWzhBu/wTEjpdgZ8I1c8i5vboYIeigMdzgY1UowYBKa2vZgVgUB32bu7gkxeg==
|
||||
optionalDependencies:
|
||||
esbuild-android-arm64 "0.13.14"
|
||||
esbuild-darwin-64 "0.13.14"
|
||||
esbuild-darwin-arm64 "0.13.14"
|
||||
esbuild-freebsd-64 "0.13.14"
|
||||
esbuild-freebsd-arm64 "0.13.14"
|
||||
esbuild-linux-32 "0.13.14"
|
||||
esbuild-linux-64 "0.13.14"
|
||||
esbuild-linux-arm "0.13.14"
|
||||
esbuild-linux-arm64 "0.13.14"
|
||||
esbuild-linux-mips64le "0.13.14"
|
||||
esbuild-linux-ppc64le "0.13.14"
|
||||
esbuild-netbsd-64 "0.13.14"
|
||||
esbuild-openbsd-64 "0.13.14"
|
||||
esbuild-sunos-64 "0.13.14"
|
||||
esbuild-windows-32 "0.13.14"
|
||||
esbuild-windows-64 "0.13.14"
|
||||
esbuild-windows-arm64 "0.13.14"
|
||||
|
||||
esbuild@^0.8.56:
|
||||
version "0.8.57"
|
||||
resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.8.57.tgz#a42d02bc2b57c70bcd0ef897fe244766bb6dd926"
|
||||
@@ -28599,6 +28707,11 @@ vm-browserify@^1.0.1:
|
||||
resolved "https://registry.npmjs.org/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"
|
||||
integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==
|
||||
|
||||
vm2@^3.9.5:
|
||||
version "3.9.5"
|
||||
resolved "https://registry.npmjs.org/vm2/-/vm2-3.9.5.tgz#5288044860b4bbace443101fcd3bddb2a0aa2496"
|
||||
integrity sha512-LuCAHZN75H9tdrAiLFf030oW7nJV5xwNMuk1ymOZwopmuK3d2H4L1Kv4+GFHgarKiLfXXLFU+7LDABHnwOkWng==
|
||||
|
||||
vscode-languageserver-types@^3.15.1:
|
||||
version "3.15.1"
|
||||
resolved "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.15.1.tgz#17be71d78d2f6236d414f0001ce1ef4d23e6b6de"
|
||||
|
||||
Reference in New Issue
Block a user