From 3e5fdfead3608da4e184a7cbc33efd3c82fa3676 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Tue, 4 Aug 2026 23:28:54 +0100 Subject: [PATCH 01/10] fix: `getAllExtensionDiscoveryPaths` to prevent external mutation of paths. The `getAllExtensionDiscoveryPaths` method signature has always been typed as returning a readonly Map, but that's just for compile-time. For run-time it was still returning the actual Map which could be mutated externally. - Fixed `getAllExtensionDiscoveryPaths` ExtensionData method to return a new Map of the `extensionDiscoveryPaths` Map, to prevent external mutation of the paths. --- src/extensionData.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/extensionData.ts b/src/extensionData.ts index 3d9305e..dbdeac4 100644 --- a/src/extensionData.ts +++ b/src/extensionData.ts @@ -350,10 +350,12 @@ export class ExtensionData { /** * Get all extension discovery paths. + * Used for logging the paths to the output channel. * * @returns {ReadonlyMap} A read-only Map containing all extension discovery paths. */ public getAllExtensionDiscoveryPaths(): ReadonlyMap { - return this.extensionDiscoveryPaths; + // Return a new Map to prevent external mutation of the internal state. + return new Map(this.extensionDiscoveryPaths); } } From b71187970c9693233f18d7eadeb8eaf5b44ac9fd Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 5 Aug 2026 00:25:38 +0100 Subject: [PATCH 02/10] feat: redact username in logging output - Implemented `redactUsername` utility function to sanitise logs by removing the OS username. - Updated logging statements to use the new redaction utility in: - `logDebugInfo` method in `Configuration` class. - `getAllExtensionDiscoveryPaths` and `prepareForLogging` methods in `ExtensionData` class. --- src/configuration.ts | 5 ++-- src/extensionData.ts | 8 +++++-- src/utils.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/configuration.ts b/src/configuration.ts index 720870e..55e6e2e 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1002,7 +1002,8 @@ export class Configuration { ), }, }; - logger.debug("Environment:", env); + + logger.debug("Environment:", utils.redactUsername(env)); // Log the extension's user configuration settings. logger.debug("Configuration settings:", this.getConfiguration()); @@ -1010,7 +1011,7 @@ export class Configuration { // Log the objects for debugging purposes. // Lang Config Filepaths. - logger.debug("The language config filepaths found are:", this.languageConfigFilePaths); + logger.debug("The language config filepaths found are:", utils.redactUsername(this.languageConfigFilePaths)); // Lang Configs. logger.debug("The language configs found are:", this.languageConfigs); diff --git a/src/extensionData.ts b/src/extensionData.ts index dbdeac4..81e0b47 100644 --- a/src/extensionData.ts +++ b/src/extensionData.ts @@ -5,7 +5,7 @@ import isWsl from "is-wsl"; import {IPackageJson} from "package-json-type"; import {logger} from "./logger"; -import {readJsonFile} from "./utils"; +import {readJsonFile, redactUsername} from "./utils"; import {ExtensionMetaData, ExtensionPaths, ExtensionMetaDataValue} from "./interfaces/extensionMetaData"; export class ExtensionData { @@ -334,6 +334,10 @@ export class ExtensionData { // Remove the packageJSON entry to avoid logging irrelevant information. extensionDataClone.delete("packageJSON"); + // Redact the username in the extensionPath. + const redactedExtensionPath = redactUsername(extensionDataClone.get("extensionPath")); + extensionDataClone.set("extensionPath", redactedExtensionPath); + return extensionDataClone; } @@ -356,6 +360,6 @@ export class ExtensionData { */ public getAllExtensionDiscoveryPaths(): ReadonlyMap { // Return a new Map to prevent external mutation of the internal state. - return new Map(this.extensionDiscoveryPaths); + return new Map(redactUsername(this.extensionDiscoveryPaths)); } } diff --git a/src/utils.ts b/src/utils.ts index 3e5f2d6..d532814 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,5 @@ import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import * as jsonc from "jsonc-parser"; import {logger} from "./logger"; @@ -254,6 +255,59 @@ export function mergeArraysBy(primaryArray: T[], secondaryArray: T[], key: ke return merged; } +/** + * Recursively redact the OS username from strings within a value (including + * nested objects, arrays, and Maps), replacing it with "". + * + * @param {T} value The value to sanitize. + * @returns {T} A sanitized copy of `value` with the username redacted. + */ +export function redactUsername(value: T): T { + // Get the current OS username using Node's userInfo() method which is + // cross-platform compatible. + const username = os.userInfo().username; + // Escape special characters in the username. + const escapedUsername = username.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Build a global case-insensitive regex if username isn't an empty string. + const usernameRegex = username ? new RegExp(escapedUsername, "gi") : null; + + /** + * Recursively redacts the OS username from strings within a value. + * @param input The value to sanitize. + * @returns The sanitized value with the username redacted. + */ + const redact = (input: unknown): unknown => { + // If the input is a string, replace any occurrences of the username with "". + if (typeof input === "string") { + let result = input; + + if (usernameRegex) { + result = result.replace(usernameRegex, ""); + } + return result; + } + + // If the input is an array, recursively redact each element. + if (Array.isArray(input)) { + return input.map(redact); + } + + // If the input is a Map, recursively redact each value and return a new Map. + if (input instanceof Map) { + return new Map([...input].map(([key, val]) => [key, redact(val)])); + } + + // If the input is an object, recursively redact each value and return a new object. + if (input !== null && typeof input === "object") { + return Object.fromEntries(Object.entries(input).map(([key, val]) => [key, redact(val)])); + } + + return input; + }; + + return redact(value) as T; +} + /** * Add development environment variables from a local .env file located in the project root. */ From bcf80695d76f86c679db7d80655b312c5193bdf3 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 5 Aug 2026 03:05:01 +0100 Subject: [PATCH 03/10] refactor: `redactUsername` to be directly implemented into the Logger. - Moved `redactUsername` function from utils into Logger, simplifying the new method, and adding it's call into the `formatMeta` method after it's transformed the data into a string. By implementing the `redactUsername` method directly into Logger, we can ensure that any calls to logger with additional meta data, will have the username automatically redacted. So even if more logs are added in future, we don't forget to redact the usernames before Logger gets it. This also fixes various copilot review comments on the previous implementation because its no longer recursing into objects or arrays, it's just replacing directly on the string immediately before logging to output. - Updated logging statements to remove the old redaction utility in: - `logDebugInfo` method in `Configuration` class. - `getAllExtensionDiscoveryPaths` and `prepareForLogging` methods in `ExtensionData` class. --- src/configuration.ts | 4 ++-- src/extensionData.ts | 8 ++----- src/logger.ts | 33 ++++++++++++++++++++++++++- src/utils.ts | 54 -------------------------------------------- 4 files changed, 36 insertions(+), 63 deletions(-) diff --git a/src/configuration.ts b/src/configuration.ts index 55e6e2e..1ce5802 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1003,7 +1003,7 @@ export class Configuration { }, }; - logger.debug("Environment:", utils.redactUsername(env)); + logger.debug("Environment:", env); // Log the extension's user configuration settings. logger.debug("Configuration settings:", this.getConfiguration()); @@ -1011,7 +1011,7 @@ export class Configuration { // Log the objects for debugging purposes. // Lang Config Filepaths. - logger.debug("The language config filepaths found are:", utils.redactUsername(this.languageConfigFilePaths)); + logger.debug("The language config filepaths found are:", this.languageConfigFilePaths); // Lang Configs. logger.debug("The language configs found are:", this.languageConfigs); diff --git a/src/extensionData.ts b/src/extensionData.ts index 81e0b47..dbdeac4 100644 --- a/src/extensionData.ts +++ b/src/extensionData.ts @@ -5,7 +5,7 @@ import isWsl from "is-wsl"; import {IPackageJson} from "package-json-type"; import {logger} from "./logger"; -import {readJsonFile, redactUsername} from "./utils"; +import {readJsonFile} from "./utils"; import {ExtensionMetaData, ExtensionPaths, ExtensionMetaDataValue} from "./interfaces/extensionMetaData"; export class ExtensionData { @@ -334,10 +334,6 @@ export class ExtensionData { // Remove the packageJSON entry to avoid logging irrelevant information. extensionDataClone.delete("packageJSON"); - // Redact the username in the extensionPath. - const redactedExtensionPath = redactUsername(extensionDataClone.get("extensionPath")); - extensionDataClone.set("extensionPath", redactedExtensionPath); - return extensionDataClone; } @@ -360,6 +356,6 @@ export class ExtensionData { */ public getAllExtensionDiscoveryPaths(): ReadonlyMap { // Return a new Map to prevent external mutation of the internal state. - return new Map(redactUsername(this.extensionDiscoveryPaths)); + return new Map(this.extensionDiscoveryPaths); } } diff --git a/src/logger.ts b/src/logger.ts index 2a86931..ac1129b 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -208,7 +208,7 @@ class Logger { data = lines.join(",\n"); } - return data; + return this.redactUsername(data); } /** @@ -232,6 +232,37 @@ class Logger { return value; } + + /** + * Redact the OS username from a string, replacing it with ``, to avoid leaking + * it into debug logs that could be shared. + * + * @param {string} text The text to redact. + * @returns {string} The text with OS username replaced with ``. + */ + private redactUsername(text: string): string { + let username: string; + + // Get the current OS username using Node's userInfo() method which is + // cross-platform compatible. It can throw an error in sandboxed/remote environments where + // the username can't be determined. So catch any errors and return the original text + // if we can't get the username. + try { + username = os.userInfo().username; + } catch { + return text; + } + + // If the username is empty, return the original text. + if (!username) { + return text; + } + + // Escape special characters in the username. + const escapedUsername = username.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Replace any occurrence of the username in the text with "", case-insensitively, and return it. + return text.replace(new RegExp(escapedUsername, "gi"), ""); + } } export const logger = new Logger(); diff --git a/src/utils.ts b/src/utils.ts index d532814..3e5f2d6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,4 @@ import * as fs from "node:fs"; -import * as os from "node:os"; import * as path from "node:path"; import * as jsonc from "jsonc-parser"; import {logger} from "./logger"; @@ -255,59 +254,6 @@ export function mergeArraysBy(primaryArray: T[], secondaryArray: T[], key: ke return merged; } -/** - * Recursively redact the OS username from strings within a value (including - * nested objects, arrays, and Maps), replacing it with "". - * - * @param {T} value The value to sanitize. - * @returns {T} A sanitized copy of `value` with the username redacted. - */ -export function redactUsername(value: T): T { - // Get the current OS username using Node's userInfo() method which is - // cross-platform compatible. - const username = os.userInfo().username; - // Escape special characters in the username. - const escapedUsername = username.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - // Build a global case-insensitive regex if username isn't an empty string. - const usernameRegex = username ? new RegExp(escapedUsername, "gi") : null; - - /** - * Recursively redacts the OS username from strings within a value. - * @param input The value to sanitize. - * @returns The sanitized value with the username redacted. - */ - const redact = (input: unknown): unknown => { - // If the input is a string, replace any occurrences of the username with "". - if (typeof input === "string") { - let result = input; - - if (usernameRegex) { - result = result.replace(usernameRegex, ""); - } - return result; - } - - // If the input is an array, recursively redact each element. - if (Array.isArray(input)) { - return input.map(redact); - } - - // If the input is a Map, recursively redact each value and return a new Map. - if (input instanceof Map) { - return new Map([...input].map(([key, val]) => [key, redact(val)])); - } - - // If the input is an object, recursively redact each value and return a new object. - if (input !== null && typeof input === "object") { - return Object.fromEntries(Object.entries(input).map(([key, val]) => [key, redact(val)])); - } - - return input; - }; - - return redact(value) as T; -} - /** * Add development environment variables from a local .env file located in the project root. */ From 5bbccc85cd67890b4325bf51b3e977d40f392eb3 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 5 Aug 2026 03:11:43 +0100 Subject: [PATCH 04/10] fix: missing node `os` import. --- src/logger.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/logger.ts b/src/logger.ts index ac1129b..0fa3921 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,3 +1,4 @@ +import * as os from "node:os"; import {OutputChannel, window} from "vscode"; import {LogLevel, logLevels} from "./interfaces/utils"; From 773ef437dc6e2e96a300b4869f159bae12e5517b Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 5 Aug 2026 03:27:54 +0100 Subject: [PATCH 05/10] feat: add `warn` log level and add `warnOnRedactionFailure` method. - Introduced new `warn` log level. - Added `warn` enum option to the `logLevel` user setting and adjusted all the options descriptions. - Implemented `warn` method in `Logger` class to handle warning messages. - Updated `logLevels` in the `utils` interface to include `warn`. - Added `warn` level in `shouldLog` method in `Logger` and adjusted all the weights. - Added new `hasWarnedAboutRedactionFailure` property to determine whether the user has already been warned about a redaction failure. - Added new `warnOnRedactionFailure` method in Logger to warn users when the username couldn't be determined and redaction failed. This method uses the new `hasWarnedAboutRedactionFailure` property to check if it's already been outputted, as this is a once per session warning. It also uses the new `warn` logger method. --- package.json | 6 ++++-- src/interfaces/utils.ts | 1 + src/logger.ts | 38 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index ade9ab5..61c069d 100644 --- a/package.json +++ b/package.json @@ -85,12 +85,14 @@ "enum": [ "debug", "info", + "warn", "error", "off" ], "markdownEnumDescriptions": [ - "Log debug, info, and errors", - "Log info and errors", + "Log debug, info, warnings, and errors", + "Log info, warnings, and errors", + "Log warnings and errors", "Log errors only", "Disable logging" ], diff --git a/src/interfaces/utils.ts b/src/interfaces/utils.ts index 6f174fb..4291e79 100644 --- a/src/interfaces/utils.ts +++ b/src/interfaces/utils.ts @@ -44,6 +44,7 @@ export type LanguageId = string; export const logLevels = { debug: "debug", info: "info", + warn: "warn", error: "error", off: "off", } as const; diff --git a/src/logger.ts b/src/logger.ts index 0fa3921..32952bb 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -27,6 +27,14 @@ class Logger { */ private logLevel: LogLevel = "debug"; + /** + * Whether the user has already been warned that username redaction is unavailable, + * so the warning is only emitted once per session. + * + * @type {boolean} + */ + private hasWarnedAboutRedactionFailure: boolean = false; + /*********** * Methods * ***********/ @@ -97,6 +105,17 @@ class Logger { } } + /** + * Sends a warning log to the output channel. + * + * @param {string} message The message to be logged. + */ + public warn(message: string): void { + if (this.shouldLog("warn")) { + this.logMessage("WARN", message); + } + } + /** * Sends a debug log message and data to the output channel if `debug` is enabled. * This is helpful for logging objects and arrays. @@ -151,8 +170,9 @@ class Logger { private shouldLog(requiredLevel: LogLevel): boolean { // Numeric weights used for level comparison. const levelWeight: Record = { - debug: 3, // Emits debug, info, and error logs - the most verbose level. - info: 2, // Emits info and error logs. + debug: 4, // Emits debug, info, warn, and error logs - the most verbose level. + info: 3, // Emits info, warn, and error logs. + warn: 2, // Emits warn and error logs. error: 1, // Emits error logs only. off: 0, // Disables all logs, except for the special "important" logs that are always emitted. }; @@ -251,11 +271,13 @@ class Logger { try { username = os.userInfo().username; } catch { + this.warnOnRedactionFailure(); return text; } // If the username is empty, return the original text. if (!username) { + this.warnOnRedactionFailure(); return text; } @@ -264,6 +286,18 @@ class Logger { // Replace any occurrence of the username in the text with "", case-insensitively, and return it. return text.replace(new RegExp(escapedUsername, "gi"), ""); } + + /** + * Warn once per session that debug logs may not have the username redacted, + * so users don't unknowingly share it in a bug report. + */ + private warnOnRedactionFailure(): void { + if (this.hasWarnedAboutRedactionFailure) { + return; + } + this.hasWarnedAboutRedactionFailure = true; + this.warn("Could not determine OS username; debug logs may not be redacted before sharing."); + } } export const logger = new Logger(); From 4565c6637d57296d8d35932e73b32ae341239530 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 5 Aug 2026 03:41:51 +0100 Subject: [PATCH 06/10] refactor: move `warn` method to below the `error method in Logger. --- src/logger.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/logger.ts b/src/logger.ts index 32952bb..accf73d 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -105,17 +105,6 @@ class Logger { } } - /** - * Sends a warning log to the output channel. - * - * @param {string} message The message to be logged. - */ - public warn(message: string): void { - if (this.shouldLog("warn")) { - this.logMessage("WARN", message); - } - } - /** * Sends a debug log message and data to the output channel if `debug` is enabled. * This is helpful for logging objects and arrays. @@ -141,6 +130,17 @@ class Logger { } } + /** + * Sends a warning log to the output channel. + * + * @param {string} message The message to be logged. + */ + public warn(message: string): void { + if (this.shouldLog("warn")) { + this.logMessage("WARN", message); + } + } + /** * Send an important message to the output channel. * This is a special log level that is always emitted regardless of the log level, From ebd946e97802d8add8b270de521a8fd9cc3689ce Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Wed, 5 Aug 2026 18:24:12 +0100 Subject: [PATCH 07/10] fix: wording of the redaction failure warning --- src/logger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/logger.ts b/src/logger.ts index accf73d..f78f321 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -296,7 +296,7 @@ class Logger { return; } this.hasWarnedAboutRedactionFailure = true; - this.warn("Could not determine OS username; debug logs may not be redacted before sharing."); + this.warn("Could not determine OS username; logs won't be redacted. Manually redact any sensitive information before sharing logs."); } } From 9a6b82ec32709b4042efedd558ac95ecf0c4c061 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Thu, 6 Aug 2026 01:38:53 +0100 Subject: [PATCH 08/10] fix: log messages that could have usernames weren't being redacted. - Added the `redactUsername` method call in the `logMessage` method to redact usernames from the log messages, as they could have them too. - Moved the method call to redact the meta data from `formatMeta` method into the `logMessage` method, so that the log message and data are both redacted from the same centralised method. --- src/logger.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/logger.ts b/src/logger.ts index f78f321..03aad46 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -192,13 +192,18 @@ class Logger { if (!this.outputChannel) { this.setupOutputChannel(); } + + message = this.redactUsername(message); + const time = new Date().toLocaleTimeString(); // Output the log message to the output channel. this.outputChannel.append(`["${level}" - ${time}] ${message}`); if (meta) { - const data: string = this.formatMeta(message, meta); + let data: string = this.formatMeta(message, meta); + + data = this.redactUsername(data); // Output the meta data to the output channel with a leading space. this.outputChannel.appendLine(` ${data}`); @@ -229,7 +234,7 @@ class Logger { data = lines.join(",\n"); } - return this.redactUsername(data); + return data; } /** From 7a450b92c8d4230c948fae1414739d7d8712f50b Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Thu, 6 Aug 2026 01:44:05 +0100 Subject: [PATCH 09/10] feat: skip username redaction if previous attempt failed. - Added a `hasWarnedAboutRedactionFailure` guard conditional at the top of the `redactUsername` method to skip redaction if a previous redact attempt failed. --- src/logger.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/logger.ts b/src/logger.ts index 03aad46..1b3d2ca 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -267,6 +267,11 @@ class Logger { * @returns {string} The text with OS username replaced with ``. */ private redactUsername(text: string): string { + // If redaction previously failed, skip attempting it again and return the original text. + if (this.hasWarnedAboutRedactionFailure) { + return text; + } + let username: string; // Get the current OS username using Node's userInfo() method which is From 6a493777b59f87d419fee1fa18dfa4491920ba18 Mon Sep 17 00:00:00 2001 From: yCodeTech Date: Thu, 6 Aug 2026 01:57:07 +0100 Subject: [PATCH 10/10] refactor: `hasWarnedAboutRedactionFailure` property into a new name. - Changed the `hasWarnedAboutRedactionFailure` property flag to `skipRedaction` to better describe it's actual job of skipping redaction attempts after failure. - Changed references to the old `hasWarnedAboutRedactionFailure` property to use the new `skipRedaction` property in the `redactUsername` and `warnOnRedactionFailure` methods. - Removed the old `hasWarnedAboutRedactionFailure` guard conditional from the `warnOnRedactionFailure` method. This is because the method will only run when the new `skipRedaction` flag is false thanks to the guard at the top of `redactUsername` method. So the extra guard is now redundant. - Revised docblocks and code comments for clarity on the redaction behaviour. --- src/logger.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/logger.ts b/src/logger.ts index 1b3d2ca..6f8bee2 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -28,12 +28,13 @@ class Logger { private logLevel: LogLevel = "debug"; /** - * Whether the user has already been warned that username redaction is unavailable, - * so the warning is only emitted once per session. + * Whether username redaction should be skipped, because it previously failed. + * Once set, further attempts are not made since the username is unlikely to become + * resolvable later in the same session. * * @type {boolean} */ - private hasWarnedAboutRedactionFailure: boolean = false; + private skipRedaction: boolean = false; /*********** * Methods * @@ -260,15 +261,16 @@ class Logger { } /** - * Redact the OS username from a string, replacing it with ``, to avoid leaking - * it into debug logs that could be shared. + * Redact the OS username from a string, replacing it with ``, + * to avoid leaking it into the logs that could be shared. * * @param {string} text The text to redact. - * @returns {string} The text with OS username replaced with ``. + * @returns {string} If redaction was possible, returns the redacted text, + * otherwise the original text. */ private redactUsername(text: string): string { // If redaction previously failed, skip attempting it again and return the original text. - if (this.hasWarnedAboutRedactionFailure) { + if (this.skipRedaction) { return text; } @@ -302,10 +304,8 @@ class Logger { * so users don't unknowingly share it in a bug report. */ private warnOnRedactionFailure(): void { - if (this.hasWarnedAboutRedactionFailure) { - return; - } - this.hasWarnedAboutRedactionFailure = true; + // Set the flag to skip further redaction attempts before warning to avoid recursion. + this.skipRedaction = true; this.warn("Could not determine OS username; logs won't be redacted. Manually redact any sensitive information before sharing logs."); } }