Skip to content

Commit 565c0ba

Browse files
authored
feat: add injectable LoggerSanitizer to mask sensitive data in logs (#16771)
* feat: add injectable LoggerSanitizer to mask sensitive data in logs Introduce LoggerSanitizer service that automatically masks credentials in log messages to prevent sensitive data leakage (e.g., proxy URLs with username:password, api keys). - Add LoggerSanitizer interface and DefaultLoggerSanitizer implementation - Integrate sanitizer into Logger.format() - Provide a base set of sanitization rules to mask any URL protocol with credentials, api keys and authtokens - Make sanitizer injectable and optional - Add unit test cases Contributed on behalf of STMicroelectronics
1 parent bf9a85e commit 565c0ba

File tree

6 files changed

+609
-5
lines changed

6 files changed

+609
-5
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: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
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:password@proxy.example.com: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:pass@secure-proxy.com: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%20word@proxy.com: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:pass@proxy.com: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:pass@ftp.example.com';
65+
const sanitized = sanitizer.sanitize(message);
66+
expect(sanitized).to.equal('ftp://****:****@ftp.example.com');
67+
});
68+
69+
it('should mask credentials in URL without port', () => {
70+
const message = 'https://user:pass@example.com/path';
71+
const sanitized = sanitizer.sanitize(message);
72+
expect(sanitized).to.equal('https://****:****@example.com/path');
73+
});
74+
75+
it('should mask credentials in URL with port', () => {
76+
const message = 'https://user:pass@example.com:8080/path';
77+
const sanitized = sanitizer.sanitize(message);
78+
expect(sanitized).to.equal('https://****:****@example.com:8080/path');
79+
});
80+
81+
it('should not over-match when text after URL contains @ symbol', () => {
82+
const message = '\"uri\": \"file:///some/path/my.llamafile\" ... \"@modelcontextprotocol/server-filesystem@latest\"';
83+
const sanitized = sanitizer.sanitize(message);
84+
expect(sanitized).to.equal('\"uri\": \"file:///some/path/my.llamafile\" ... \"@modelcontextprotocol/server-filesystem@latest\"');
85+
});
86+
87+
it('should mask credentials in sftp URL', () => {
88+
const message = 'sftp://user:pass@sftp.example.com:22/path';
89+
const sanitized = sanitizer.sanitize(message);
90+
expect(sanitized).to.equal('sftp://****:****@sftp.example.com:22/path');
91+
});
92+
93+
it('should mask credentials in ssh URL', () => {
94+
const message = 'ssh://git:token@github.com/repo';
95+
const sanitized = sanitizer.sanitize(message);
96+
expect(sanitized).to.equal('ssh://****:****@github.com/repo');
97+
});
98+
99+
it('should mask credentials in ws URL', () => {
100+
const message = 'ws://user:pass@websocket.example.com';
101+
const sanitized = sanitizer.sanitize(message);
102+
expect(sanitized).to.equal('ws://****:****@websocket.example.com');
103+
});
104+
105+
it('should mask credentials in wss URL', () => {
106+
const message = 'wss://user:pass@secure-websocket.example.com';
107+
const sanitized = sanitizer.sanitize(message);
108+
expect(sanitized).to.equal('wss://****:****@secure-websocket.example.com');
109+
});
110+
111+
it('should mask credentials in socks proxy URL', () => {
112+
const message = 'socks://user:pass@socks-proxy.com:1080';
113+
const sanitized = sanitizer.sanitize(message);
114+
expect(sanitized).to.equal('socks://****:****@socks-proxy.com:1080');
115+
});
116+
117+
it('should mask credentials in socks4 proxy URL', () => {
118+
const message = 'socks4://user:pass@socks4-proxy.com:1080';
119+
const sanitized = sanitizer.sanitize(message);
120+
expect(sanitized).to.equal('socks4://****:****@socks4-proxy.com:1080');
121+
});
122+
123+
it('should mask credentials in socks5 proxy URL', () => {
124+
const message = 'socks5://user:pass@socks5-proxy.com:1080';
125+
const sanitized = sanitizer.sanitize(message);
126+
expect(sanitized).to.equal('socks5://****:****@socks5-proxy.com:1080');
127+
});
128+
129+
it('should mask credentials in git URL', () => {
130+
const message = 'git://user:token@github.com/org/repo.git';
131+
const sanitized = sanitizer.sanitize(message);
132+
expect(sanitized).to.equal('git://****:****@github.com/org/repo.git');
133+
});
134+
135+
it('should not mask mailto links (no credentials format)', () => {
136+
const message = 'mailto:user@example.com';
137+
const sanitized = sanitizer.sanitize(message);
138+
expect(sanitized).to.equal('mailto:user@example.com');
139+
});
140+
141+
it('should mask credentials in any protocol with standard URL format', () => {
142+
const message = 'customprotocol://user:pass@custom.server.com';
143+
const sanitized = sanitizer.sanitize(message);
144+
expect(sanitized).to.equal('customprotocol://****:****@custom.server.com');
145+
});
146+
147+
it('should be case-insensitive for protocol', () => {
148+
const message = 'HTTP://user:pass@proxy.com:8080';
149+
const sanitized = sanitizer.sanitize(message);
150+
expect(sanitized).to.equal('HTTP://****:****@proxy.com:8080');
151+
});
152+
153+
it('should mask multiple URLs in a single string', () => {
154+
const message = 'Connecting to http://user1:pass1@proxy1.com and http://user2:pass2@proxy2.com';
155+
const sanitized = sanitizer.sanitize(message);
156+
expect(sanitized).to.equal('Connecting to http://****:****@proxy1.com and http://****:****@proxy2.com');
157+
});
158+
159+
it('should mask multiple URLs with different protocols', () => {
160+
const message = 'HTTP: http://u:p@h1.com, SOCKS: socks5://u:p@h2.com, Git: git://u:p@h3.com';
161+
const sanitized = sanitizer.sanitize(message);
162+
expect(sanitized).to.equal('HTTP: http://****:****@h1.com, SOCKS: socks5://****:****@h2.com, Git: git://****:****@h3.com');
163+
});
164+
165+
it('should mask credentials in log messages containing URLs', () => {
166+
const message = 'Failed to connect to http://admin:secret@internal-proxy.com:8080';
167+
const sanitized = sanitizer.sanitize(message);
168+
expect(sanitized).to.equal('Failed to connect to http://****:****@internal-proxy.com:8080');
169+
});
170+
171+
it('should handle error stack traces with URLs', () => {
172+
const stack = `Error: Connection failed
173+
at Request.http://user:pass@proxy.com:8080/api
174+
at processRequest (index.js:10:5)`;
175+
const sanitized = sanitizer.sanitize(stack);
176+
expect(sanitized).to.contain('http://****:****@proxy.com:8080');
177+
expect(sanitized).not.to.contain('user:pass');
178+
});
179+
180+
it('should return message unchanged if no sensitive data', () => {
181+
const message = 'Normal log message without sensitive data';
182+
const sanitized = sanitizer.sanitize(message);
183+
expect(sanitized).to.equal(message);
184+
});
185+
186+
it('should mask api_key values in JSON format', () => {
187+
const message = '"api_key": "secret123"';
188+
const sanitized = sanitizer.sanitize(message);
189+
expect(sanitized).to.equal('"api_key": "****"');
190+
});
191+
192+
it('should mask API_KEY values in JSON format (case-insensitive)', () => {
193+
const message = '"API_KEY": "SECRET123"';
194+
const sanitized = sanitizer.sanitize(message);
195+
expect(sanitized).to.equal('"API_KEY": "****"');
196+
});
197+
198+
it('should mask api-key values in JSON format with hyphen separator', () => {
199+
const message = '"api-key": "my-token"';
200+
const sanitized = sanitizer.sanitize(message);
201+
expect(sanitized).to.equal('"api-key": "****"');
202+
});
203+
204+
it('should mask apikey values in JSON format without separator', () => {
205+
const message = '"apikey": "token123"';
206+
const sanitized = sanitizer.sanitize(message);
207+
expect(sanitized).to.equal('"apikey": "****"');
208+
});
209+
210+
it('should mask api key in JSON with single quotes', () => {
211+
const message = "'api_key': 'secret123'";
212+
const sanitized = sanitizer.sanitize(message);
213+
expect(sanitized).to.equal("'api_key': '****'");
214+
});
215+
216+
it('should mask prefixed api keys like anthropic_api_key in JSON', () => {
217+
const message = '"anthropic_api_key": "sk-ant-123456"';
218+
const sanitized = sanitizer.sanitize(message);
219+
expect(sanitized).to.equal('"anthropic_api_key": "****"');
220+
});
221+
222+
it('should mask prefixed api keys like openai_api_key in JSON', () => {
223+
const message = '"openai_api_key": "sk-abc123"';
224+
const sanitized = sanitizer.sanitize(message);
225+
expect(sanitized).to.equal('"openai_api_key": "****"');
226+
});
227+
228+
it('should mask prefixed api keys like GOOGLE_API_KEY in JSON', () => {
229+
const message = '"GOOGLE_API_KEY": "AIzaSy123"';
230+
const sanitized = sanitizer.sanitize(message);
231+
expect(sanitized).to.equal('"GOOGLE_API_KEY": "****"');
232+
});
233+
234+
it('should mask multiple api keys in JSON object', () => {
235+
const message = '{ "anthropic_api_key": "sk-123", "openai_api_key": "sk-456" }';
236+
const sanitized = sanitizer.sanitize(message);
237+
expect(sanitized).to.equal('{ "anthropic_api_key": "****", "openai_api_key": "****" }');
238+
});
239+
240+
it('should mask authtoken values in JSON format without separator', () => {
241+
const message = '"authtoken": "github_pat_zxzxzxzxzxzxzxzxz"';
242+
const sanitized = sanitizer.sanitize(message);
243+
expect(sanitized).to.equal('"authtoken": "****"');
244+
});
245+
246+
it('should mask auth_token values in JSON format with underscore separator', () => {
247+
const message = '"auth_token": "github_pat_zxzxzxzxzxzxzxzxz"';
248+
const sanitized = sanitizer.sanitize(message);
249+
expect(sanitized).to.equal('"auth_token": "****"');
250+
});
251+
252+
it('should mask auth-token values in JSON format with hyphen separator', () => {
253+
const message = '"auth-token": "github_pat_zxzxzxzxzxzxzxzxz"';
254+
const sanitized = sanitizer.sanitize(message);
255+
expect(sanitized).to.equal('"auth-token": "****"');
256+
});
257+
258+
it('should mask serverAuthToken in JSON format', () => {
259+
const message = '"serverAuthToken": "github_pat_zxzxzxzxzxzxzxzxz"';
260+
const sanitized = sanitizer.sanitize(message);
261+
expect(sanitized).to.equal('"serverAuthToken": "****"');
262+
});
263+
264+
it('should mask escaped quotes from JSON.stringify', () => {
265+
const message = '\\"api_key\\": \\"secret123\\"';
266+
const sanitized = sanitizer.sanitize(message);
267+
expect(sanitized).to.equal('\\"api_key\\": \\"****\\"');
268+
});
269+
270+
it('should mask dot-notation settings apiKey in JSON', () => {
271+
const message = '"ai-features.huggingFace.apiKey": "hf_xxxxxxxxxxxx"';
272+
const sanitized = sanitizer.sanitize(message);
273+
expect(sanitized).to.equal('"ai-features.huggingFace.apiKey": "****"');
274+
});
275+
276+
it('should mask nested settings with apiKey in JSON', () => {
277+
const message = '"ai-features.openAiOfficial.openAiApiKey": "sk-xxxxxxxx"';
278+
const sanitized = sanitizer.sanitize(message);
279+
expect(sanitized).to.equal('"ai-features.openAiOfficial.openAiApiKey": "****"');
280+
});
281+
282+
it('should mask settings serverAuthToken in JSON', () => {
283+
const message = '"serverAuthToken": "ghp_xxxxxxxxxxxx"';
284+
const sanitized = sanitizer.sanitize(message);
285+
expect(sanitized).to.equal('"serverAuthToken": "****"');
286+
});
287+
});
288+
});

0 commit comments

Comments
 (0)