Merge pull request #23453 from backstage/blam/redact-scaffolder-secrets

scaffolder: Redacting of `secrets` in log streams
This commit is contained in:
Ben Lambert
2024-03-12 10:26:49 +01:00
committed by GitHub
16 changed files with 297 additions and 73 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-node': minor
---
**DEPRECATION** - Deprecated the `logStream` in the `ActionContext`. Please move to using `ctx.logger.x` instead.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-scaffolder-backend': minor
'@backstage/plugin-scaffolder-backend-module-cookiecutter': patch
'@backstage/plugin-scaffolder-backend-module-rails': patch
---
Move away from using `ctx.logStream`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Enable the redaction of secrets using the redacting logger and the secrets from the `TaskSpec`
@@ -23,6 +23,7 @@ import { createFetchCookiecutterAction } from './cookiecutter';
import { join } from 'path';
import type { ActionContext } from '@backstage/plugin-scaffolder-node';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { Writable } from 'stream';
const executeShellCommand = jest.fn();
const commandExists = jest.fn();
@@ -166,7 +167,7 @@ describe('fetch:cookiecutter', () => {
join(mockTmpDir, 'template'),
'--verbose',
],
logStream: mockContext.logStream,
logStream: expect.any(Writable),
}),
);
});
@@ -187,7 +188,7 @@ describe('fetch:cookiecutter', () => {
},
workingDir: '/input',
envVars: { HOME: '/tmp' },
logStream: mockContext.logStream,
logStream: expect.any(Writable),
}),
);
});
@@ -25,7 +25,7 @@ import { ScmIntegrations } from '@backstage/integration';
import commandExists from 'command-exists';
import fs from 'fs-extra';
import path, { resolve as resolvePath } from 'path';
import { Writable } from 'stream';
import { PassThrough, Writable } from 'stream';
import {
createTemplateAction,
fetchContents,
@@ -245,10 +245,15 @@ export function createFetchCookiecutterAction(options: {
_extensions: ctx.input.extensions,
};
const logStream = new PassThrough();
logStream.on('data', chunk => {
ctx.logger.info(chunk.toString());
});
// Will execute the template in ./template and put the result in ./result
await cookiecutter.run({
workspacePath: workDir,
logStream: ctx.logStream,
logStream,
values: values,
imageName: ctx.input.imageName,
templateDir: templateDir,
@@ -35,6 +35,7 @@ import { createFetchRailsAction } from './index';
import { fetchContents } from '@backstage/plugin-scaffolder-node';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { Writable } from 'stream';
describe('fetch:rails', () => {
const mockDir = createMockDirectory();
@@ -104,7 +105,7 @@ describe('fetch:rails', () => {
expect(mockRailsTemplater.run).toHaveBeenCalledWith({
workspacePath: mockContext.workspacePath,
logStream: mockContext.logStream,
logStream: expect.any(Writable),
values: mockContext.input.values,
});
});
@@ -120,7 +121,7 @@ describe('fetch:rails', () => {
expect(mockRailsTemplater.run).toHaveBeenCalledWith({
workspacePath: mockContext.workspacePath,
logStream: mockContext.logStream,
logStream: expect.any(Writable),
values: {
...mockContext.input.values,
imageName: 'foo/rails-custom-image',
@@ -26,6 +26,7 @@ import {
import { resolve as resolvePath } from 'path';
import { RailsNewRunner } from './railsNewRunner';
import { PassThrough } from 'stream';
/**
* Creates the `fetch:rails` Scaffolder action.
@@ -215,10 +216,15 @@ export function createFetchRailsAction(options: {
throw new Error(`Image ${imageName} is not allowed`);
}
const logStream = new PassThrough();
logStream.on('data', chunk => {
ctx.logger.info(chunk.toString());
});
// Will execute the template in ./template and put the result in ./result
await templateRunner.run({
workspacePath: workDir,
logStream: ctx.logStream,
logStream,
values: { ...ctx.input.values, imageName },
});
+26 -25
View File
@@ -1,18 +1,30 @@
{
"name": "@backstage/plugin-scaffolder-backend",
"description": "The Backstage backend plugin that helps you create new things",
"version": "1.22.0-next.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"description": "The Backstage backend plugin that helps you create new things",
"backstage": {
"role": "backend-plugin"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"backstage"
],
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/scaffolder-backend"
},
"license": "Apache-2.0",
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"main": "src/index.ts",
"types": "src/index.ts",
"typesVersions": {
"*": {
"alpha": [
@@ -23,27 +35,21 @@
]
}
},
"backstage": {
"role": "backend-plugin"
},
"homepage": "https://backstage.io",
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/scaffolder-backend"
},
"keywords": [
"backstage"
"files": [
"dist",
"migrations",
"config.d.ts",
"assets"
],
"scripts": {
"start": "backstage-cli package start",
"build": "backstage-cli package build",
"build:assets": "node scripts/build-nunjucks.js",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"test": "backstage-cli package test",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"clean": "backstage-cli package clean",
"build:assets": "node scripts/build-nunjucks.js"
"start": "backstage-cli package start",
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/backend-common": "workspace:^",
@@ -81,6 +87,7 @@
"jsonschema": "^1.2.6",
"knex": "^3.0.0",
"lodash": "^4.17.21",
"logform": "^2.3.2",
"luxon": "^3.0.0",
"nunjucks": "^3.2.3",
"p-limit": "^3.1.0",
@@ -105,11 +112,5 @@
"supertest": "^6.1.3",
"wait-for-expect": "^3.0.2"
},
"files": [
"dist",
"migrations",
"config.d.ts",
"assets"
],
"configSchema": "config.d.ts"
}
@@ -49,7 +49,7 @@ export function createCatalogWriteAction() {
examples,
supportsDryRun: true,
async handler(ctx) {
ctx.logStream.write(`Writing catalog-info.yaml`);
ctx.logger.info(`Writing catalog-info.yaml`);
const { filePath, entity } = ctx.input;
const path = filePath ?? 'catalog-info.yaml';
@@ -15,23 +15,23 @@
*/
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { Writable } from 'stream';
import { createDebugLogAction } from './log';
import { join } from 'path';
import yaml from 'yaml';
import { examples } from './log.examples';
import { createMockDirectory } from '@backstage/backend-test-utils';
import { Logger } from 'winston';
describe('debug:log examples', () => {
const logStream = {
write: jest.fn(),
} as jest.Mocked<Partial<Writable>> as jest.Mocked<Writable>;
const logger = {
info: jest.fn(),
} as unknown as jest.Mocked<Logger>;
const mockDir = createMockDirectory();
const workspacePath = mockDir.resolve('workspace');
const mockContext = createMockActionContext({
logStream,
logger,
workspacePath,
});
@@ -51,8 +51,7 @@ describe('debug:log examples', () => {
input: yaml.parse(examples[0].example).steps[0].input,
});
expect(logStream.write).toHaveBeenCalledTimes(1);
expect(logStream.write).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Hello Backstage!'),
);
});
@@ -63,11 +62,10 @@ describe('debug:log examples', () => {
input: yaml.parse(examples[1].example).steps[0].input,
});
expect(logStream.write).toHaveBeenCalledTimes(1);
expect(logStream.write).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('README.md'),
);
expect(logStream.write).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining(join('a-directory', 'index.md')),
);
});
@@ -15,21 +15,21 @@
*/
import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils';
import { Writable } from 'stream';
import { Logger } from 'winston';
import { createDebugLogAction } from './log';
import { join } from 'path';
import yaml from 'yaml';
import { createMockDirectory } from '@backstage/backend-test-utils';
describe('debug:log', () => {
const logStream = {
write: jest.fn(),
} as jest.Mocked<Partial<Writable>> as jest.Mocked<Writable>;
const logger = {
info: jest.fn(),
} as unknown as jest.Mocked<Logger>;
const mockDir = createMockDirectory();
const workspacePath = mockDir.resolve('workspace');
const mockContext = createMockActionContext({ workspacePath, logStream });
const mockContext = createMockActionContext({ workspacePath, logger });
const action = createDebugLogAction();
@@ -41,12 +41,6 @@ describe('debug:log', () => {
jest.resetAllMocks();
});
it('should do nothing', async () => {
await action.handler(mockContext);
expect(logStream.write).toHaveBeenCalledTimes(0);
});
it('should log the workspace content, if active', async () => {
const context = {
...mockContext,
@@ -57,11 +51,10 @@ describe('debug:log', () => {
await action.handler(context);
expect(logStream.write).toHaveBeenCalledTimes(1);
expect(logStream.write).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('README.md'),
);
expect(logStream.write).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining(join('a-directory', 'index.md')),
);
});
@@ -76,8 +69,7 @@ describe('debug:log', () => {
await action.handler(context);
expect(logStream.write).toHaveBeenCalledTimes(1);
expect(logStream.write).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Hello Backstage!'),
);
});
@@ -94,11 +86,10 @@ describe('debug:log', () => {
await action.handler(context);
expect(logStream.write).toHaveBeenCalledTimes(1);
expect(logStream.write).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('README.md'),
);
expect(logStream.write).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining(join('a-directory', 'index.md')),
);
});
@@ -115,8 +106,7 @@ describe('debug:log', () => {
await action.handler(context);
expect(logStream.write).toHaveBeenCalledTimes(1);
expect(logStream.write).toHaveBeenCalledWith(
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Hello Backstage!'),
);
});
@@ -60,12 +60,12 @@ export function createDebugLogAction() {
ctx.logger.info(JSON.stringify(ctx.input, null, 2));
if (ctx.input?.message) {
ctx.logStream.write(ctx.input.message);
ctx.logger.info(ctx.input.message);
}
if (ctx.input?.listWorkspace) {
const files = await recursiveReadDir(ctx.workspacePath);
ctx.logStream.write(
ctx.logger.info(
`Workspace:\n${files
.map(f => ` - ${relative(ctx.workspacePath, f)}`)
.join('\n')}`,
@@ -54,6 +54,8 @@ import { scaffolderActionRules } from '../../service/rules';
import { actionExecutePermission } from '@backstage/plugin-scaffolder-common/alpha';
import { TaskRecovery } from '@backstage/plugin-scaffolder-common';
import { PermissionsService } from '@backstage/backend-plugin-api';
import { loggerToWinstonLogger } from '@backstage/backend-common';
import { WinstonLogger } from './logger';
type NunjucksWorkflowRunnerOptions = {
workingDirectory: string;
@@ -100,26 +102,43 @@ const createStepLogger = ({
task: TaskContext;
step: TaskStep;
}) => {
const metadata = { stepId: step.id };
const taskLogger = winston.createLogger({
const stepLogStream = new PassThrough();
stepLogStream.on('data', async data => {
const message = data.toString().trim();
if (message?.length > 1) {
await task.emitLog(message, { stepId: step.id });
}
});
const taskLogger = WinstonLogger.create({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple(),
),
defaultMeta: {},
transports: [
new winston.transports.Console(),
new winston.transports.Stream({ stream: stepLogStream }),
],
});
taskLogger.addRedactions(Object.values(task.secrets ?? {}));
// This stream logger should be deprecated. We're going to replace it with
// just using the logger directly, as all those logs get written to step logs
// using the stepLogStream above.
// Initially this stream used to be the only way to write to the client logs, but that
// has changed over time, there's not really a need for this anymore.
// You can just create a simple wrapper like the below in your action to write to the main logger.
// This way we also get recactions for free.
const streamLogger = new PassThrough();
streamLogger.on('data', async data => {
const message = data.toString().trim();
if (message?.length > 1) {
await task.emitLog(message, metadata);
taskLogger.info(message);
}
});
taskLogger.add(new winston.transports.Stream({ stream: streamLogger }));
return { taskLogger, streamLogger };
};
@@ -355,7 +374,8 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
await action.handler({
input: iteration.input,
secrets: task.secrets ?? {},
logger: taskLogger,
// TODO(blam): move to LoggerService and away from Winston
logger: loggerToWinstonLogger(taskLogger),
logStream: streamLogger,
workspacePath,
async checkpoint<U extends JsonValue>(
@@ -0,0 +1,182 @@
/*
* Copyright 2024 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 {
LoggerService,
RootLoggerService,
} from '@backstage/backend-plugin-api';
import { JsonObject } from '@backstage/types';
import { Format, TransformableInfo } from 'logform';
import {
Logger,
format,
createLogger,
transports,
transport as Transport,
} from 'winston';
/**
* Escapes a given string to be used inside a RegExp.
*
* Taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions
*/
const escapeRegExp = (text: string) => {
return text.replace(/[.*+?^${}(\)|[\]\\]/g, '\\$&');
};
interface WinstonLoggerOptions {
meta?: JsonObject;
level: string;
format: Format;
transports: Transport[];
}
export class WinstonLogger implements RootLoggerService {
#winston: Logger;
#addRedactions?: (redactions: Iterable<string>) => void;
/**
* Creates a {@link WinstonLogger} instance.
*/
static create(options: WinstonLoggerOptions): WinstonLogger {
const redacter = WinstonLogger.redacter();
let logger = createLogger({
level: options.level,
format: format.combine(redacter.format, options.format),
transports: options.transports ?? new transports.Console(),
});
if (options.meta) {
logger = logger.child(options.meta);
}
return new WinstonLogger(logger, redacter.add);
}
/**
* Creates a winston log formatter for redacting secrets.
*/
static redacter(): {
format: Format;
add: (redactions: Iterable<string>) => void;
} {
const redactionSet = new Set<string>();
let redactionPattern: RegExp | undefined = undefined;
return {
format: format(info => {
if (redactionPattern && typeof info.message === 'string') {
info.message = info.message.replace(redactionPattern, '[REDACTED]');
}
if (redactionPattern && typeof info.stack === 'string') {
info.stack = info.stack.replace(redactionPattern, '[REDACTED]');
}
return info;
})(),
add(newRedactions) {
let added = 0;
for (const redactionToTrim of newRedactions) {
// Trimming the string ensures that we don't accdentally get extra
// newlines or other whitespace interfering with the redaction; this
// can happen for example when using string literals in yaml
const redaction = redactionToTrim.trim();
// Exclude secrets that are empty or just one character in length. These
// typically mean that you are running local dev or tests, or using the
// --lax flag which sets things to just 'x'.
if (redaction.length <= 1) {
continue;
}
if (!redactionSet.has(redaction)) {
redactionSet.add(redaction);
added += 1;
}
}
if (added > 0) {
const redactions = Array.from(redactionSet)
.map(r => escapeRegExp(r))
.join('|');
redactionPattern = new RegExp(`(${redactions})`, 'g');
}
},
};
}
/**
* Creates a pretty printed winston log formatter.
*/
static colorFormat(): Format {
const colorizer = format.colorize();
return format.combine(
format.timestamp(),
format.colorize({
colors: {
timestamp: 'dim',
prefix: 'blue',
field: 'cyan',
debug: 'grey',
},
}),
format.printf((info: TransformableInfo) => {
const { timestamp, level, message, plugin, service, ...fields } = info;
const prefix = plugin || service;
const timestampColor = colorizer.colorize('timestamp', timestamp);
const prefixColor = colorizer.colorize('prefix', prefix);
const extraFields = Object.entries(fields)
.map(
([key, value]) =>
`${colorizer.colorize('field', `${key}`)}=${value}`,
)
.join(' ');
return `${timestampColor} ${prefixColor} ${level} ${message} ${extraFields}`;
}),
);
}
private constructor(
winston: Logger,
addRedactions?: (redactions: Iterable<string>) => void,
) {
this.#winston = winston;
this.#addRedactions = addRedactions;
}
error(message: string, meta?: JsonObject): void {
this.#winston.error(message, meta);
}
warn(message: string, meta?: JsonObject): void {
this.#winston.warn(message, meta);
}
info(message: string, meta?: JsonObject): void {
this.#winston.info(message, meta);
}
debug(message: string, meta?: JsonObject): void {
this.#winston.debug(message, meta);
}
child(meta: JsonObject): LoggerService {
return new WinstonLogger(this.#winston.child(meta));
}
addRedactions(redactions: Iterable<string>) {
this.#addRedactions?.(redactions);
}
}
@@ -31,7 +31,9 @@ export type ActionContext<
TActionInput extends JsonObject,
TActionOutput extends JsonObject = JsonObject,
> = {
// TODO(blam): move this to LoggerService
logger: Logger;
/** @deprecated - use `ctx.logger` instead */
logStream: Writable;
secrets?: TaskSecrets;
workspacePath: string;
+1
View File
@@ -8766,6 +8766,7 @@ __metadata:
jsonschema: ^1.2.6
knex: ^3.0.0
lodash: ^4.17.21
logform: ^2.3.2
luxon: ^3.0.0
nunjucks: ^3.2.3
p-limit: ^3.1.0