diff --git a/lib/logger/index.ts b/lib/logger/index.ts index 6fe6dd27515d79acff76a467fa3a365e2b37a98b..9b838a1924ea325f6edb60a6ee3f4229b7c1f571 100644 --- a/lib/logger/index.ts +++ b/lib/logger/index.ts @@ -5,7 +5,7 @@ import { RenovateStream } from './pretty-stdout'; import configSerializer from './config-serializer'; import errSerializer from './err-serializer'; import cmdSerializer from './cmd-serializer'; -import { ErrorStream } from './utils'; +import { ErrorStream, withSanitizer } from './utils'; let meta = {}; export interface LogError { @@ -49,7 +49,7 @@ const bunyanLogger = bunyan.createLogger({ stream: errors as any, type: 'raw', }, - ], + ].map(withSanitizer), }); const logFactory = (level: bunyan.LogLevelString): any => { @@ -105,7 +105,7 @@ export function setMeta(obj: any) { export /* istanbul ignore next */ function addStream( stream: bunyan.Stream ): void { - bunyanLogger.addStream(stream); + bunyanLogger.addStream(withSanitizer(stream)); } export function levels(name: string, level: bunyan.LogLevel): void { diff --git a/lib/logger/utils.ts b/lib/logger/utils.ts index 7730d2c7d54f40c52e33d2c9202ed4d302bb30e6..d81ed623244819a65038d937053ddc618eb2e578 100644 --- a/lib/logger/utils.ts +++ b/lib/logger/utils.ts @@ -1,4 +1,7 @@ +import fs from 'fs-extra'; +import bunyan from 'bunyan'; import { Stream } from 'stream'; +import { sanitize } from '../util/sanitize'; export interface BunyanRecord extends Record<string, any> { level: number; @@ -32,3 +35,67 @@ export class ErrorStream extends Stream { return this._errors; } } + +function sanitizeValue(value: any, seen = new WeakMap()) { + if (Array.isArray(value)) { + const length = value.length; + const arrayResult = Array(length); + seen.set(value, arrayResult); + for (let idx = 0; idx < length; idx += 1) { + const val = value[idx]; + arrayResult[idx] = seen.has(val) + ? seen.get(val) + : sanitizeValue(val, seen); + } + return arrayResult; + } + + const valueType = typeof value; + + if (value != null && valueType !== 'function' && valueType === 'object') { + const objectResult: Record<string, any> = {}; + seen.set(value, objectResult); + for (const [key, val] of Object.entries<any>(value)) { + objectResult[key] = seen.has(val) + ? seen.get(val) + : sanitizeValue(val, seen); + } + return objectResult; + } + + return valueType === 'string' ? sanitize(value) : value; +} + +export function withSanitizer(streamConfig): bunyan.Stream { + if (streamConfig.type === 'rotating-file') + throw new Error("Rotating files aren't supported"); + + const stream = streamConfig.stream; + if (stream && stream.writable) { + const write = (chunk: BunyanRecord, enc, cb) => { + const raw = sanitizeValue(chunk); + const result = + streamConfig.type === 'raw' + ? raw + : JSON.stringify(raw, bunyan.safeCycles()).replace(/\n?$/, '\n'); + stream.write(result, enc, cb); + }; + + return { + ...streamConfig, + type: 'raw', + stream: { write }, + }; + } + + if (streamConfig.path) { + const fileStream = fs.createWriteStream(streamConfig.path, { + flags: 'a', + encoding: 'utf8', + }); + + return withSanitizer({ ...streamConfig, stream: fileStream }); + } + + throw new Error("Missing 'stream' or 'path' for bunyan stream"); +} diff --git a/test/logger/index.spec.ts b/test/logger/index.spec.ts index c895df090bad55c2b5dbc622da9c2d1f51af807d..30eea4810c1ebac12612fe6aad6e633d0f72375b 100644 --- a/test/logger/index.spec.ts +++ b/test/logger/index.spec.ts @@ -1,7 +1,18 @@ -import { logger, setMeta, levels, getErrors } from '../../lib/logger'; +import _fs from 'fs-extra'; +import { + logger, + setMeta, + levels, + getErrors, + addStream, +} from '../../lib/logger'; +import { add } from '../../lib/util/host-rules'; jest.unmock('../../lib/logger'); +jest.mock('fs-extra'); +const fs: any = _fs; + describe('logger', () => { it('inits', () => { expect(logger).toBeDefined(); @@ -31,4 +42,96 @@ describe('logger', () => { logger.error({ some: 'meta' }, 'message'); expect(getErrors()).toMatchSnapshot(); }); + + it('should contain path or stream parameters', () => { + expect(() => + addStream({ + name: 'logfile', + level: 'error', + }) + ).toThrow("Missing 'stream' or 'path' for bunyan stream"); + }); + + it("doesn't support rotating files", () => { + expect(() => + addStream({ + name: 'logfile', + path: 'file.log', + level: 'error', + type: 'rotating-file', + }) + ).toThrow("Rotating files aren't supported"); + }); + + it('supports file-based logging', () => { + let chunk = null; + fs.createWriteStream.mockReturnValueOnce({ + writable: true, + write(x) { + chunk = x; + }, + }); + + addStream({ + name: 'logfile', + path: 'file.log', + level: 'error', + }); + + logger.error('foo'); + + expect(JSON.parse(chunk).msg).toEqual('foo'); + }); + + it('handles cycles', () => { + let logged = null; + fs.createWriteStream.mockReturnValueOnce({ + writable: true, + write(x) { + logged = JSON.parse(x); + }, + }); + + addStream({ + name: 'logfile', + path: 'file.log', + level: 'error', + }); + + const meta = { foo: null, bar: [] }; + meta.foo = meta; + meta.bar.push(meta); + logger.error(meta, 'foo'); + expect(logged.msg).toEqual('foo'); + expect(logged.foo.foo).toEqual('[Circular]'); + expect(logged.foo.bar).toEqual(['[Circular]']); + expect(logged.bar).toEqual('[Circular]'); + }); + + it('sanitizes secrets', () => { + let logged = null; + fs.createWriteStream.mockReturnValueOnce({ + writable: true, + write(x) { + logged = JSON.parse(x); + }, + }); + + addStream({ + name: 'logfile', + path: 'file.log', + level: 'error', + }); + add({ password: 'secret"password' }); + + logger.error({ + foo: 'secret"password', + bar: ['somethingelse', 'secret"password'], + }); + + expect(logged.foo).not.toEqual('secret"password'); + expect(logged.bar[0]).toEqual('somethingelse'); + expect(logged.foo).toContain('redacted'); + expect(logged.bar[1]).toContain('redacted'); + }); });