Skip to content

Commit f61b65c

Browse files
committed
feat: add injectable LoggerSanitizer to mask credentials in logs
Introduce LoggerSanitizer service that automatically masks credentials in log messages to prevent sensitive data leakage (e.g., proxy URLs with username:password). - Add LoggerSanitizer interface and DefaultLoggerSanitizer implementation - Integrate sanitizer into Logger.format() for all string messages and errors - Support any URL protocol with credentials (protocol://user:pass@host) - Make sanitizer injectable and optional for backward compatibility - Allow adopters to extend or replace sanitization behavior via rebind - Add unit test cases Contributed on behalf of STMicroelectronics
1 parent 33d4bdc commit f61b65c

File tree

6 files changed

+374
-3
lines changed

6 files changed

+374
-3
lines changed

packages/core/src/common/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export * from './event';
2626
export * from './inversify-utils';
2727
export * from './listener';
2828
export * from './logger';
29+
export * from './logger-sanitizer';
2930
export * from './lsp-types';
3031
export * from './menu';
3132
export * from './message-rpc';

packages/core/src/common/logger-binding.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { interfaces } from 'inversify';
1818
import { ILogger, Logger, LoggerName, rootLoggerName } from './logger';
1919
import { LoggerWatcher } from './logger-watcher';
20+
import { DefaultLoggerSanitizer, LoggerSanitizer } from './logger-sanitizer';
2021

2122
export function bindCommonLogger(bind: interfaces.Bind): void {
2223
bind(LoggerName).toConstantValue(rootLoggerName);
@@ -26,6 +27,7 @@ export function bindCommonLogger(bind: interfaces.Bind): void {
2627
return logger.child(getName(ctx.currentRequest)!);
2728
}).when(request => getName(request) !== undefined);
2829
bind(LoggerWatcher).toSelf().inSingletonScope();
30+
bind(LoggerSanitizer).to(DefaultLoggerSanitizer).inSingletonScope();
2931
}
3032

3133
function getName(request: interfaces.Request): string | undefined {
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// *****************************************************************************
2+
// Copyright (C) 2025 STMicroelectronics GmbH.
3+
//
4+
// This program and the accompanying materials are made available under the
5+
// terms of the Eclipse Public License v. 2.0 which is available at
6+
// http://www.eclipse.org/legal/epl-2.0.
7+
//
8+
// This Source Code may also be made available under the following Secondary
9+
// Licenses when the conditions for such availability set forth in the Eclipse
10+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
// with the GNU Classpath Exception which is available at
12+
// https://www.gnu.org/software/classpath/license.html.
13+
//
14+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15+
// *****************************************************************************
16+
17+
import { expect } from 'chai';
18+
import { DefaultLoggerSanitizer } from './logger-sanitizer';
19+
20+
describe('DefaultLoggerSanitizer', () => {
21+
let sanitizer: DefaultLoggerSanitizer;
22+
23+
beforeEach(() => {
24+
sanitizer = new DefaultLoggerSanitizer();
25+
});
26+
27+
describe('sanitize', () => {
28+
it('should mask credentials in http URL', () => {
29+
const message = 'http://username:[email protected]:8080';
30+
const sanitized = sanitizer.sanitize(message);
31+
expect(sanitized).to.equal('http://****:****@proxy.example.com:8080');
32+
});
33+
34+
it('should mask credentials in https URL', () => {
35+
const message = 'https://user:[email protected]:443/path';
36+
const sanitized = sanitizer.sanitize(message);
37+
expect(sanitized).to.equal('https://****:****@secure-proxy.com:443/path');
38+
});
39+
40+
it('should return URL unchanged if no credentials present', () => {
41+
const message = 'http://proxy.example.com:8080';
42+
const sanitized = sanitizer.sanitize(message);
43+
expect(sanitized).to.equal('http://proxy.example.com:8080');
44+
});
45+
46+
it('should return empty string for empty string input', () => {
47+
const sanitized = sanitizer.sanitize('');
48+
expect(sanitized).to.equal('');
49+
});
50+
51+
it('should handle complex passwords with special characters', () => {
52+
const message = 'http://user:p%40ss%[email protected]:8080';
53+
const sanitized = sanitizer.sanitize(message);
54+
expect(sanitized).to.equal('http://****:****@proxy.com:8080');
55+
});
56+
57+
it('should handle URL with path and query parameters', () => {
58+
const message = 'http://user:[email protected]:8080/path?query=value';
59+
const sanitized = sanitizer.sanitize(message);
60+
expect(sanitized).to.equal('http://****:****@proxy.com:8080/path?query=value');
61+
});
62+
63+
it('should mask credentials in ftp URL', () => {
64+
const message = 'ftp://user:[email protected]';
65+
const sanitized = sanitizer.sanitize(message);
66+
expect(sanitized).to.equal('ftp://****:****@ftp.example.com');
67+
});
68+
69+
it('should mask credentials in sftp URL', () => {
70+
const message = 'sftp://user:[email protected]:22/path';
71+
const sanitized = sanitizer.sanitize(message);
72+
expect(sanitized).to.equal('sftp://****:****@sftp.example.com:22/path');
73+
});
74+
75+
it('should mask credentials in ssh URL', () => {
76+
const message = 'ssh://git:[email protected]/repo';
77+
const sanitized = sanitizer.sanitize(message);
78+
expect(sanitized).to.equal('ssh://****:****@github.com/repo');
79+
});
80+
81+
it('should mask credentials in ws URL', () => {
82+
const message = 'ws://user:[email protected]';
83+
const sanitized = sanitizer.sanitize(message);
84+
expect(sanitized).to.equal('ws://****:****@websocket.example.com');
85+
});
86+
87+
it('should mask credentials in wss URL', () => {
88+
const message = 'wss://user:[email protected]';
89+
const sanitized = sanitizer.sanitize(message);
90+
expect(sanitized).to.equal('wss://****:****@secure-websocket.example.com');
91+
});
92+
93+
it('should mask credentials in socks proxy URL', () => {
94+
const message = 'socks://user:[email protected]:1080';
95+
const sanitized = sanitizer.sanitize(message);
96+
expect(sanitized).to.equal('socks://****:****@socks-proxy.com:1080');
97+
});
98+
99+
it('should mask credentials in socks4 proxy URL', () => {
100+
const message = 'socks4://user:[email protected]:1080';
101+
const sanitized = sanitizer.sanitize(message);
102+
expect(sanitized).to.equal('socks4://****:****@socks4-proxy.com:1080');
103+
});
104+
105+
it('should mask credentials in socks5 proxy URL', () => {
106+
const message = 'socks5://user:[email protected]:1080';
107+
const sanitized = sanitizer.sanitize(message);
108+
expect(sanitized).to.equal('socks5://****:****@socks5-proxy.com:1080');
109+
});
110+
111+
it('should mask credentials in git URL', () => {
112+
const message = 'git://user:[email protected]/org/repo.git';
113+
const sanitized = sanitizer.sanitize(message);
114+
expect(sanitized).to.equal('git://****:****@github.com/org/repo.git');
115+
});
116+
117+
it('should not mask mailto links (no credentials format)', () => {
118+
const message = 'mailto:[email protected]';
119+
const sanitized = sanitizer.sanitize(message);
120+
expect(sanitized).to.equal('mailto:[email protected]');
121+
});
122+
123+
it('should mask credentials in any protocol with standard URL format', () => {
124+
const message = 'customprotocol://user:[email protected]';
125+
const sanitized = sanitizer.sanitize(message);
126+
expect(sanitized).to.equal('customprotocol://****:****@custom.server.com');
127+
});
128+
129+
it('should be case-insensitive for protocol', () => {
130+
const message = 'HTTP://user:[email protected]:8080';
131+
const sanitized = sanitizer.sanitize(message);
132+
expect(sanitized).to.equal('HTTP://****:****@proxy.com:8080');
133+
});
134+
135+
it('should mask multiple URLs in a single string', () => {
136+
const message = 'Connecting to http://user1:[email protected] and http://user2:[email protected]';
137+
const sanitized = sanitizer.sanitize(message);
138+
expect(sanitized).to.equal('Connecting to http://****:****@proxy1.com and http://****:****@proxy2.com');
139+
});
140+
141+
it('should mask multiple URLs with different protocols', () => {
142+
const message = 'HTTP: http://u:[email protected], SOCKS: socks5://u:[email protected], Git: git://u:[email protected]';
143+
const sanitized = sanitizer.sanitize(message);
144+
expect(sanitized).to.equal('HTTP: http://****:****@h1.com, SOCKS: socks5://****:****@h2.com, Git: git://****:****@h3.com');
145+
});
146+
147+
it('should mask credentials in log messages containing URLs', () => {
148+
const message = 'Failed to connect to http://admin:[email protected]:8080';
149+
const sanitized = sanitizer.sanitize(message);
150+
expect(sanitized).to.equal('Failed to connect to http://****:****@internal-proxy.com:8080');
151+
});
152+
153+
it('should handle error stack traces with URLs', () => {
154+
const stack = `Error: Connection failed
155+
at Request.http://user:[email protected]:8080/api
156+
at processRequest (index.js:10:5)`;
157+
const sanitized = sanitizer.sanitize(stack);
158+
expect(sanitized).to.contain('http://****:****@proxy.com:8080');
159+
expect(sanitized).not.to.contain('user:pass');
160+
});
161+
162+
it('should return message unchanged if no sensitive data', () => {
163+
const message = 'Normal log message without sensitive data';
164+
const sanitized = sanitizer.sanitize(message);
165+
expect(sanitized).to.equal(message);
166+
});
167+
});
168+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// *****************************************************************************
2+
// Copyright (C) 2025 STMicroelectronics GmbH.
3+
//
4+
// This program and the accompanying materials are made available under the
5+
// terms of the Eclipse Public License v. 2.0 which is available at
6+
// http://www.eclipse.org/legal/epl-2.0.
7+
//
8+
// This Source Code may also be made available under the following Secondary
9+
// Licenses when the conditions for such availability set forth in the Eclipse
10+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
// with the GNU Classpath Exception which is available at
12+
// https://www.gnu.org/software/classpath/license.html.
13+
//
14+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15+
// *****************************************************************************
16+
17+
import { injectable } from 'inversify';
18+
19+
export const LoggerSanitizer = Symbol('LoggerSanitizer');
20+
21+
/**
22+
* Service for sanitizing log messages to remove sensitive information.
23+
*
24+
* Adopters can rebind this service to customize sanitization behavior,
25+
* for example to mask additional patterns like API keys or tokens.
26+
*
27+
* @example
28+
* ```ts
29+
* // Custom sanitizer that extends the default behavior
30+
* @injectable()
31+
* class CustomLoggerSanitizer extends DefaultLoggerSanitizer {
32+
* override sanitize(message: string): string {
33+
* let sanitized = super.sanitize(message);
34+
* // Add custom sanitization, e.g., mask API keys
35+
* sanitized = sanitized.replace(/api[_-]?key[=:]\s*['"]?[\w-]+['"]?/gi, 'api_key=****');
36+
* return sanitized;
37+
* }
38+
* }
39+
*
40+
* // In your module:
41+
* rebind(LoggerSanitizer).to(CustomLoggerSanitizer).inSingletonScope();
42+
* ```
43+
*/
44+
export interface LoggerSanitizer {
45+
/**
46+
* Sanitizes a log message by masking sensitive information.
47+
*
48+
* @param message The log message to sanitize
49+
* @returns The sanitized message with sensitive data masked
50+
*/
51+
sanitize(message: string): string;
52+
}
53+
54+
/**
55+
* Regex pattern to match URLs with credentials.
56+
* Matches any URL with format: protocol://user:pass@host
57+
* Protocol is any sequence of alphanumeric characters followed by ://
58+
*/
59+
const URL_CREDENTIALS_PATTERN = /([a-z][a-z0-9+.-]*:\/\/)([^:]+):([^@]+)@/gi;
60+
61+
/**
62+
* Default implementation of LoggerSanitizer that masks credentials in URLs.
63+
*
64+
* Currently masks:
65+
* - Credentials in any URL with format protocol://user:pass@host
66+
* (e.g., http://user:pass@host -> http://****:****@host)
67+
*
68+
* Adopters can extend this class to add additional sanitization patterns.
69+
*/
70+
@injectable()
71+
export class DefaultLoggerSanitizer implements LoggerSanitizer {
72+
73+
sanitize(message: string): string {
74+
return this.maskUrlCredentials(message);
75+
}
76+
77+
/**
78+
* Masks credentials in URLs within the message.
79+
* Replaces username:password with ****:****
80+
*
81+
* @param message The message potentially containing URLs with credentials
82+
* @returns The message with URL credentials masked
83+
*/
84+
protected maskUrlCredentials(message: string): string {
85+
return message.replace(URL_CREDENTIALS_PATTERN, '$1****:****@');
86+
}
87+
}

packages/core/src/common/logger.spec.ts

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,29 @@
1616

1717
import { expect } from 'chai';
1818
import { MockLogger } from './test/mock-logger';
19-
import { setRootLogger, unsetRootLogger } from './logger';
19+
import { setRootLogger, unsetRootLogger, Logger } from './logger';
20+
import { DefaultLoggerSanitizer, LoggerSanitizer } from './logger-sanitizer';
21+
22+
/* eslint-disable @typescript-eslint/no-explicit-any */
23+
24+
/**
25+
* Testable subclass of Logger that exposes protected methods for testing.
26+
*/
27+
class TestableLogger extends Logger {
28+
constructor(sanitizer?: LoggerSanitizer) {
29+
super();
30+
// Bypass dependency injection for testing
31+
(this as any).sanitizer = sanitizer;
32+
}
33+
34+
public testFormat(value: any): any {
35+
return this.format(value);
36+
}
37+
38+
public testSanitize(message: string): string {
39+
return this.sanitize(message);
40+
}
41+
}
2042

2143
describe('logger', () => {
2244

@@ -43,4 +65,86 @@ describe('logger', () => {
4365
).to.not.throw(ReferenceError);
4466
});
4567

68+
describe('Logger sanitization', () => {
69+
let loggerWithSanitizer: TestableLogger;
70+
let loggerWithoutSanitizer: TestableLogger;
71+
72+
beforeEach(() => {
73+
loggerWithSanitizer = new TestableLogger(new DefaultLoggerSanitizer());
74+
loggerWithoutSanitizer = new TestableLogger(undefined);
75+
});
76+
77+
describe('format', () => {
78+
it('should sanitize string messages with credentials', () => {
79+
const message = 'Connecting to http://user:[email protected]:8080';
80+
const formatted = loggerWithSanitizer.testFormat(message);
81+
expect(formatted).to.equal('Connecting to http://****:****@proxy.com:8080');
82+
});
83+
84+
it('should sanitize error stack traces with credentials', () => {
85+
const error = new Error('Connection failed to http://admin:[email protected]');
86+
const formatted = loggerWithSanitizer.testFormat(error);
87+
expect(formatted).to.include('http://****:****@server.com');
88+
expect(formatted).not.to.include('admin:secret');
89+
});
90+
91+
it('should return non-string values unchanged', () => {
92+
const obj = { url: 'http://user:[email protected]' };
93+
const formatted = loggerWithSanitizer.testFormat(obj);
94+
expect(formatted).to.equal(obj);
95+
});
96+
97+
it('should return numbers unchanged', () => {
98+
const formatted = loggerWithSanitizer.testFormat(42);
99+
expect(formatted).to.equal(42);
100+
});
101+
102+
it('should handle messages without credentials', () => {
103+
const message = 'Normal log message';
104+
const formatted = loggerWithSanitizer.testFormat(message);
105+
expect(formatted).to.equal('Normal log message');
106+
});
107+
});
108+
109+
describe('sanitize without sanitizer', () => {
110+
it('should return message unchanged when no sanitizer is injected', () => {
111+
const message = 'http://user:[email protected]:8080';
112+
const sanitized = loggerWithoutSanitizer.testSanitize(message);
113+
expect(sanitized).to.equal(message);
114+
});
115+
});
116+
117+
describe('sanitize with sanitizer', () => {
118+
it('should mask credentials in URLs', () => {
119+
const message = 'http://user:[email protected]:8080';
120+
const sanitized = loggerWithSanitizer.testSanitize(message);
121+
expect(sanitized).to.equal('http://****:****@proxy.com:8080');
122+
});
123+
124+
it('should mask multiple URLs in a single message', () => {
125+
const message = 'Primary: http://u1:[email protected], Fallback: https://u2:[email protected]';
126+
const sanitized = loggerWithSanitizer.testSanitize(message);
127+
expect(sanitized).to.equal('Primary: http://****:****@proxy1.com, Fallback: https://****:****@proxy2.com');
128+
});
129+
130+
it('should mask credentials across different protocols', () => {
131+
const message = 'Proxies: socks5://u:[email protected], ftp://u:[email protected], wss://u:[email protected]';
132+
const sanitized = loggerWithSanitizer.testSanitize(message);
133+
expect(sanitized).to.equal('Proxies: socks5://****:****@socks.com, ftp://****:****@ftp.com, wss://****:****@ws.com');
134+
});
135+
});
136+
137+
describe('custom sanitizer', () => {
138+
it('should allow custom sanitizer implementation', () => {
139+
const customSanitizer: LoggerSanitizer = {
140+
sanitize: (msg: string) => msg.replace(/secret/gi, '***')
141+
};
142+
const loggerWithCustomSanitizer = new TestableLogger(customSanitizer);
143+
144+
const message = 'The secret code is SECRET';
145+
const sanitized = loggerWithCustomSanitizer.testSanitize(message);
146+
expect(sanitized).to.equal('The *** code is ***');
147+
});
148+
});
149+
});
46150
});

0 commit comments

Comments
 (0)