diff --git a/packages/logging-interceptor/README.md b/packages/logging-interceptor/README.md index c4226a2e..c5a8c5ef 100644 --- a/packages/logging-interceptor/README.md +++ b/packages/logging-interceptor/README.md @@ -232,6 +232,14 @@ import { LoggingInterceptor } from '@algoan/nestjs-logging-interceptor'; new LoggingInterceptor({ disableMasking: true, // Ignore masking options in the entire applications maskingPlaceholder: 'hidden', // Replace the default placeholder '****' by a custom one + mask: { + requestHeader: { + password: true, // Mask the header 'password' in the request + authorization: (header: string | string[]) => { + ... // Handle the header value to keep non sensitive data for instance + } + }, + }, }), }, ], diff --git a/packages/logging-interceptor/src/logging.interceptor.ts b/packages/logging-interceptor/src/logging.interceptor.ts index 05596871..b3df480b 100644 --- a/packages/logging-interceptor/src/logging.interceptor.ts +++ b/packages/logging-interceptor/src/logging.interceptor.ts @@ -1,3 +1,4 @@ +import { IncomingHttpHeaders } from 'http'; import { CallHandler, ExecutionContext, @@ -14,6 +15,48 @@ import { tap } from 'rxjs/operators'; import { parse, stringify } from 'flatted'; import { LogOptions, METHOD_LOG_METADATA } from './log.decorator'; +/** + * Logging interceptor options + */ +export interface LoggingInterceptorOptions { + /** + * User prefix to add to the logs + */ + userPrefix?: string; + /** + * Disable masking + */ + disableMasking?: boolean; + /** + * Masking placeholder + */ + maskingPlaceholder?: string; + /** + * Masking options to apply to all routes + */ + mask?: LoggingInterceptorMaskingOptions; +} + +/** + * Masking options of the logging interceptor + */ +export interface LoggingInterceptorMaskingOptions { + /** + * Masking options to apply to the headers of the request + */ + requestHeader?: RequestHeaderMask; +} + +/** + * Masking options of the request headers + */ +export interface RequestHeaderMask { + /** + * Mask of a request header. The key is the header name and the value is a boolean or a function that returns the data to log. + */ + [headerKey: string]: boolean | ((headerValue: string | string[]) => unknown); +} + /** * Interceptor that logs input/output requests */ @@ -24,11 +67,13 @@ export class LoggingInterceptor implements NestInterceptor { private userPrefix: string; private disableMasking: boolean; private maskingPlaceholder: string | undefined; + private mask: LoggingInterceptorMaskingOptions | undefined; - constructor(@Optional() options?: { userPrefix?: string; disableMasking?: boolean; maskingPlaceholder?: string }) { + constructor(@Optional() options?: LoggingInterceptorOptions) { this.userPrefix = options?.userPrefix ?? ''; this.disableMasking = options?.disableMasking ?? false; this.maskingPlaceholder = options?.maskingPlaceholder ?? '****'; + this.mask = options?.mask; } /** @@ -54,6 +99,15 @@ export class LoggingInterceptor implements NestInterceptor { public setMaskingPlaceholder(placeholder: string | undefined): void { this.maskingPlaceholder = placeholder; } + + /** + * Set the masking options + * @param mask + */ + public setMask(mask: LoggingInterceptorMaskingOptions): void { + this.mask = mask; + } + /** * Intercept method, logs before and after the request being processed * @param context details about the current request @@ -68,13 +122,14 @@ export class LoggingInterceptor implements NestInterceptor { // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions const maskedBody = options?.mask?.request ? this.maskData(body, options.mask.request) : body; + const maskedHeaders = this.maskHeaders(headers); this.logger.log( { message, method, body: maskedBody, - headers, + headers: maskedHeaders, }, ctx, ); @@ -209,4 +264,52 @@ export class LoggingInterceptor implements NestInterceptor { return parsedData; } + + /** + * Mask the given headers + * @param headers the headers to mask + * @returns the masked headers + */ + private maskHeaders(headers: IncomingHttpHeaders): Record { + if (this.disableMasking || this.mask?.requestHeader === undefined) { + return headers; + } + + return Object.keys(headers).reduce>( + (maskedHeaders: Record, headerKey: string): Record => { + const headerValue = headers[headerKey]; + const mask = this.mask?.requestHeader?.[headerKey]; + + if (headerValue === undefined) { + return maskedHeaders; + } + + if (mask === true) { + return { + ...maskedHeaders, + [headerKey]: this.maskingPlaceholder, + }; + } + + if (typeof mask === 'function') { + try { + return { + ...maskedHeaders, + [headerKey]: mask(headerValue), + }; + } catch (err) { + this.logger.warn(`LoggingInterceptor - Masking error for header ${headerKey}`, err); + + return { + ...maskedHeaders, + [headerKey]: this.maskingPlaceholder, + }; + } + } + + return maskedHeaders; + }, + headers, + ); + } } diff --git a/packages/logging-interceptor/test/logging.interceptor.test.ts b/packages/logging-interceptor/test/logging.interceptor.test.ts index 03483f2f..7ec35dbb 100644 --- a/packages/logging-interceptor/test/logging.interceptor.test.ts +++ b/packages/logging-interceptor/test/logging.interceptor.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable max-lines */ import { BadRequestException, HttpStatus, @@ -154,7 +155,7 @@ describe('Logging interceptor', () => { }); }); - describe('Masking options', () => { + describe('@Log - Masking options', () => { const placeholder = '****'; it('allows to mask given properties of the request body', async () => { @@ -397,4 +398,107 @@ describe('Logging interceptor', () => { ]); }); }); + + describe('LoggingInterceptor - Masking options', () => { + const placeholder = '****'; + + it('allows to mask the whole content of a request header', async () => { + const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor; + interceptor.setMask({ requestHeader: { authorization: true } }); + const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log'); + const url: string = `/cats/ok`; + + await request(app.getHttpServer()).get(url).set('authorization', 'access-token').expect(HttpStatus.OK); + + expect(logSpy.mock.calls[0][0].headers.authorization).toEqual(placeholder); + }); + + it('allows to mask a request header with a specific handler function', async () => { + const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor; + interceptor.setMask({ + requestHeader: { + authorization: (header: string | string[]) => { + if (typeof header === 'string') { + const [type, value] = header.split(' '); + + return { type, value }; + } else { + return header; + } + }, + }, + }); + const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log'); + const url: string = `/cats/ok`; + + await request(app.getHttpServer()).get(url).set('authorization', 'Bearer JWT').expect(HttpStatus.OK); + + expect(logSpy.mock.calls[0][0].headers.authorization).toEqual({ + type: 'Bearer', + value: 'JWT', + }); + }); + + it('should not mask a request header if the corresponding mask is undefined', async () => { + const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor; + interceptor.setMask({ requestHeader: {} }); + const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log'); + const url: string = `/cats/ok`; + + await request(app.getHttpServer()).get(url).set('authorization', 'Bearer JWT').expect(HttpStatus.OK); + + expect(logSpy.mock.calls[0][0].headers.authorization).toBe('Bearer JWT'); + }); + + it('should not mask a request header if the corresponding mask is false', async () => { + const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor; + interceptor.setMask({ requestHeader: { authorization: false } }); + const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log'); + const url: string = `/cats/ok`; + + await request(app.getHttpServer()).get(url).set('authorization', 'Bearer JWT').expect(HttpStatus.OK); + + expect(logSpy.mock.calls[0][0].headers.authorization).toBe('Bearer JWT'); + }); + + it('should not modify the request header if it is not passed with the request but defined in masking option', async () => { + const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor; + interceptor.setMask({ requestHeader: { authorization: true } }); + const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log'); + const url: string = `/cats/ok`; + + await request(app.getHttpServer()).get(url).expect(HttpStatus.OK); + + expect(logSpy.mock.calls[0][0].headers.authorization).toBeUndefined(); + }); + + it('should not fail if the masking function throws an error and mask the whole header as fallback', async () => { + const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor; + interceptor.setMask({ + requestHeader: { + authorization: () => { + throw new Error('This is an error'); + }, + }, + }); + const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log'); + const url: string = `/cats/ok`; + + await request(app.getHttpServer()).get(url).set('authorization', 'Bearer JWT').expect(HttpStatus.OK); + + expect(logSpy.mock.calls[0][0].headers.authorization).toBe(placeholder); + }); + + it('should not mask request headers if masking is disabled', async () => { + const interceptor = app.get(ApplicationConfig).getGlobalInterceptors()[0] as LoggingInterceptor; + interceptor.setMask({ requestHeader: { authorization: true } }); + interceptor.setDisableMasking(true); + const logSpy: jest.SpyInstance = jest.spyOn(Logger.prototype, 'log'); + const url: string = `/cats/ok`; + + await request(app.getHttpServer()).get(url).set('authorization', 'Bearer JWT').expect(HttpStatus.OK); + + expect(logSpy.mock.calls[0][0].headers.authorization).toBe('Bearer JWT'); + }); + }); });