Skip to content

Commit 9560e3f

Browse files
committed
logging redact pii
1 parent 7c8dfc1 commit 9560e3f

4 files changed

Lines changed: 218 additions & 7 deletions

File tree

backend/src/common/middleware/logging.middleware.spec.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,16 @@ describe('LoggingMiddleware', () => {
4141
listeners[event] = listeners[event] ?? [];
4242
listeners[event].push(cb);
4343
}) as unknown as Response['on'],
44-
} as Partial<Response>;
44+
getEventListeners: () => listeners,
45+
json: jest.fn(function (data: any) {
46+
this.json = jest.fn(function (d) { return this; });
47+
return this;
48+
}),
49+
send: jest.fn(function (data: any) {
50+
this.send = jest.fn(function (d) { return this; });
51+
return this;
52+
}),
53+
} as any;
4554
}
4655

4756
it('should be defined', () => {
@@ -114,4 +123,94 @@ describe('LoggingMiddleware', () => {
114123

115124
expect(next).toHaveBeenCalled();
116125
});
126+
127+
it('logs response body with redaction on finish', () => {
128+
const errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => {});
129+
const req = makeReq();
130+
const res = makeRes() as any;
131+
const next: NextFunction = jest.fn();
132+
133+
middleware.use(req as Request, res as Response, next);
134+
135+
// Capture and use the json method
136+
res.json({ token: 'secret_token', userId: 'user123' });
137+
138+
// Trigger finish event
139+
const listeners = res.getEventListeners();
140+
if (listeners['finish']) {
141+
listeners['finish'].forEach((cb: () => void) => cb());
142+
}
143+
144+
// Response should be logged but with redacted token
145+
const loggedMessages = [...logSpy.mock.calls, ...errorSpy.mock.calls];
146+
const finishLogCall = loggedMessages.find((call: any[]) => call[0]?.includes('Outgoing Response'));
147+
expect(finishLogCall).toBeDefined();
148+
expect(finishLogCall?.[0]).toContain(REDACTED);
149+
expect(finishLogCall?.[0]).toContain('user123');
150+
});
151+
152+
it('redacts sensitive fields in response body', () => {
153+
const req = makeReq();
154+
const res = makeRes() as any;
155+
const next: NextFunction = jest.fn();
156+
157+
middleware.use(req as Request, res as Response, next);
158+
159+
// Send a response with sensitive data
160+
res.json({ email: 'user@example.com', name: 'Alice', password: 'secret' });
161+
162+
// Trigger finish event
163+
const listeners = res.getEventListeners();
164+
if (listeners['finish']) {
165+
listeners['finish'].forEach((cb: () => void) => cb());
166+
}
167+
168+
const loggedMessages = [...logSpy.mock.calls];
169+
const finishLogCall = loggedMessages.find((call: any[]) => call[0]?.includes('Outgoing Response'));
170+
expect(finishLogCall?.[0]).not.toContain('user@example.com');
171+
expect(finishLogCall?.[0]).not.toContain('secret');
172+
expect(finishLogCall?.[0]).toContain(REDACTED);
173+
expect(finishLogCall?.[0]).toContain('Alice');
174+
});
175+
176+
it('logs (no body) when response has no body', () => {
177+
const req = makeReq({ method: 'GET' });
178+
const res = makeRes() as any;
179+
const next: NextFunction = jest.fn();
180+
181+
middleware.use(req as Request, res as Response, next);
182+
183+
// Don't call json or send, so responseBody stays undefined
184+
// Trigger finish event
185+
const listeners = res.getEventListeners();
186+
if (listeners['finish']) {
187+
listeners['finish'].forEach((cb: () => void) => cb());
188+
}
189+
190+
const loggedMessages = [...logSpy.mock.calls];
191+
const finishLogCall = loggedMessages.find((call: any[]) => call[0]?.includes('Outgoing Response'));
192+
expect(finishLogCall?.[0]).toContain('(no body)');
193+
});
194+
195+
it('handles stale context gracefully when finish event fires', () => {
196+
const req = makeReq();
197+
const res = makeRes() as any;
198+
const next: NextFunction = jest.fn();
199+
200+
middleware.use(req as Request, res as Response, next);
201+
202+
// Manually clear context to simulate stale state
203+
const requestContextService = new RequestContextService();
204+
// requestContextService.clearContext(); // This is a no-op, so context remains
205+
206+
res.json({ userId: 'user123' });
207+
208+
// Trigger finish event - should not throw
209+
const listeners = res.getEventListeners();
210+
expect(() => {
211+
if (listeners['finish']) {
212+
listeners['finish'].forEach((cb: () => void) => cb());
213+
}
214+
}).not.toThrow();
215+
});
117216
});

backend/src/common/middleware/logging.middleware.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,45 @@ export class LoggingMiddleware implements NestMiddleware {
2424
`[${correlationId}] [${requestId}] Incoming Request: ${method} ${originalUrl} - IP: ${ip ?? ''} - Headers: ${JSON.stringify(redactedHeaders)} - Body: ${JSON.stringify(redactedBody)}`,
2525
);
2626

27+
// Store response data by intercepting json() and send() methods
28+
let responseBody: unknown = undefined;
29+
const originalJson = res.json.bind(res);
30+
const originalSend = res.send.bind(res);
31+
32+
res.json = function (data: any) {
33+
responseBody = data;
34+
return originalJson(data);
35+
};
36+
37+
res.send = function (data: any) {
38+
// Try to parse if string looks like JSON
39+
if (typeof data === 'string' && data.startsWith('{')) {
40+
try {
41+
responseBody = JSON.parse(data);
42+
} catch {
43+
responseBody = data;
44+
}
45+
} else {
46+
responseBody = data;
47+
}
48+
return originalSend(data);
49+
};
50+
2751
// Set up cleanup on response finish
2852
res.on('finish', () => {
2953
const { statusCode } = res;
3054
const duration = Date.now() - startTime;
3155
const user = (req as Request & { user?: { id?: string } }).user;
3256
const userId = user?.id ?? 'anonymous';
3357

34-
const message = `[${correlationId}] [${requestId}] Outgoing Response: ${method} ${originalUrl} - Status: ${statusCode} - Duration: ${duration}ms - User: ${userId}`;
58+
// Redact response body if available
59+
const redactedResponse = responseBody !== undefined ? redact(responseBody) : undefined;
60+
const responseLog =
61+
redactedResponse !== undefined
62+
? `${JSON.stringify(redactedResponse)}`
63+
: '(no body)';
64+
65+
const message = `[${correlationId}] [${requestId}] Outgoing Response: ${method} ${originalUrl} - Status: ${statusCode} - Duration: ${duration}ms - User: ${userId} - Body: ${responseLog}`;
3566

3667
if (statusCode >= 500) {
3768
this.logger.error(message);

backend/src/common/services/logger.service.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,72 @@ describe('LoggerService', () => {
115115
service.log({ foo: 'bar' });
116116
expect(mockWinston.info).toHaveBeenCalledWith('{"foo":"bar"}', expect.any(Object));
117117
});
118+
119+
it('redacts sensitive fields in object messages', () => {
120+
service.log({ password: 'secret123', username: 'alice' }, 'TestCtx');
121+
expect(mockWinston.info).toHaveBeenCalledWith(
122+
expect.stringContaining('[REDACTED]'),
123+
expect.objectContaining({ context: 'TestCtx' }),
124+
);
125+
// Verify secret is not in the logged message
126+
const [loggedMessage] = mockWinston.info.mock.calls[0];
127+
expect(loggedMessage).not.toContain('secret123');
128+
expect(loggedMessage).toContain('alice');
129+
});
130+
131+
it('redacts sensitive fields in error messages with objects', () => {
132+
service.error({ authorization: 'Bearer token', action: 'login' }, undefined, 'TestCtx');
133+
const [loggedMessage] = mockWinston.error.mock.calls[0];
134+
expect(loggedMessage).toContain('[REDACTED]');
135+
expect(loggedMessage).not.toContain('Bearer token');
136+
expect(loggedMessage).toContain('login');
137+
});
138+
139+
it('redacts sensitive fields in warn messages', () => {
140+
service.warn({ api_key: 'key-123', status: 'warning' }, 'TestCtx');
141+
const [loggedMessage] = mockWinston.warn.mock.calls[0];
142+
expect(loggedMessage).toContain('[REDACTED]');
143+
expect(loggedMessage).not.toContain('key-123');
144+
});
145+
146+
it('redacts sensitive fields in debug messages', () => {
147+
service.debug({ email: 'user@example.com', userId: '123' }, 'TestCtx');
148+
const [loggedMessage] = mockWinston.debug.mock.calls[0];
149+
expect(loggedMessage).toContain('[REDACTED]');
150+
expect(loggedMessage).not.toContain('user@example.com');
151+
});
152+
153+
it('redacts nested sensitive data in objects', () => {
154+
service.log(
155+
{ user: { email: 'secret@example.com', name: 'Bob', wallet_address: 'GAB123' } },
156+
'TestCtx',
157+
);
158+
const [loggedMessage] = mockWinston.info.mock.calls[0];
159+
expect(loggedMessage).toContain('[REDACTED]');
160+
expect(loggedMessage).not.toContain('secret@example.com');
161+
expect(loggedMessage).not.toContain('GAB123');
162+
expect(loggedMessage).toContain('Bob');
163+
});
164+
165+
it('does not redact string messages (trusted by caller)', () => {
166+
const message = 'User logged in successfully';
167+
service.log(message, 'TestCtx');
168+
expect(mockWinston.info).toHaveBeenCalledWith(message, expect.any(Object));
169+
});
170+
171+
it('handles arrays of objects with sensitive fields', () => {
172+
service.log(
173+
[
174+
{ password: 'p1', name: 'a' },
175+
{ password: 'p2', name: 'b' },
176+
],
177+
'TestCtx',
178+
);
179+
const [loggedMessage] = mockWinston.info.mock.calls[0];
180+
expect(loggedMessage).toContain('[REDACTED]');
181+
expect(loggedMessage).not.toContain('p1');
182+
expect(loggedMessage).not.toContain('p2');
183+
expect(loggedMessage).toContain('a');
184+
expect(loggedMessage).toContain('b');
185+
});
118186
});

backend/src/common/services/logger.service.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,37 @@ export class LoggerService implements NestLoggerService {
1515
return { context: context ?? 'Application', ...this.requestContextService.getLogContext() };
1616
}
1717

18+
/**
19+
* Convert a message to string, redacting sensitive data if it's an object.
20+
* Strings are logged as-is (assumed to be safe by caller).
21+
* Objects are redacted before JSON stringification to prevent PII/secrets leakage.
22+
*/
23+
private sanitizeMessage(message: any): string {
24+
if (typeof message === 'string') {
25+
return message;
26+
}
27+
const redactedMessage = redact(message);
28+
return JSON.stringify(redactedMessage);
29+
}
30+
1831
log(message: any, context?: string) {
19-
this.logger.info(typeof message === 'string' ? message : JSON.stringify(message), this.meta(context));
32+
this.logger.info(this.sanitizeMessage(message), this.meta(context));
2033
}
2134

2235
error(message: any, trace?: string, context?: string) {
23-
this.logger.error(typeof message === 'string' ? message : JSON.stringify(message), { ...this.meta(context), ...(trace ? { trace } : {}) });
36+
this.logger.error(this.sanitizeMessage(message), { ...this.meta(context), ...(trace ? { trace } : {}) });
2437
}
2538

2639
warn(message: any, context?: string) {
27-
this.logger.warn(typeof message === 'string' ? message : JSON.stringify(message), this.meta(context));
40+
this.logger.warn(this.sanitizeMessage(message), this.meta(context));
2841
}
2942

3043
debug(message: any, context?: string) {
31-
this.logger.debug(typeof message === 'string' ? message : JSON.stringify(message), this.meta(context));
44+
this.logger.debug(this.sanitizeMessage(message), this.meta(context));
3245
}
3346

3447
verbose(message: any, context?: string) {
35-
this.logger.verbose(typeof message === 'string' ? message : JSON.stringify(message), this.meta(context));
48+
this.logger.verbose(this.sanitizeMessage(message), this.meta(context));
3649
}
3750

3851
// Method for structured logging

0 commit comments

Comments
 (0)