From 8ec530c65e07f3978e3a43c0a6c6377d86ee30b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Thu, 29 Jan 2026 04:09:37 +0100 Subject: [PATCH] CmdPal: GEH per partes; part 1: error report builder, sanitizer and internals tools setting page (#44140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary of the Pull Request This PR adds three parts of the original big bad global error handler (error report builder, sanitization and internal tools UI). ### Error Report Generation - `ErrorReportBuilder`: Produces a detailed, technical report with system context. - Comprehensive data: OS version, architecture, culture, app version, elevation status, etc. - Exception analysis: Coalesces nested exception messages and HRESULT details for clearer diagnostics.
Example

This is an error report generated by Windows Command Palette.
If you are seeing this, it means something went a little sideways in the
app.
You can help us fix it by filing a report at
https://aka.ms/powerToysReportBug.

(While you’re at it, give the details below a quick skim — just to make
sure there’s nothing personal you’d prefer not to share. It’s rare, but
sometimes little surprises sneak in.)
============================================================
Summary:
  Message:               Test exception; thrown from the UI thread
  Type:                  System.NotImplementedException
  Source:                Microsoft.CmdPal.UI
  Time:                  2025-08-25 18:54:44.3854569
  HRESULT:               0x80004001 (-2147467263)
  Context:               MainThreadException

Application:
  App version:           0.0.1.0
  Is elevated:           no

Environment:
  OS version:            Microsoft Windows 10.0.26120
  OS architecture:       X64
  Runtime identifier:    win-x64
  Framework:             .NET 9.0.8
  Process architecture:  X64
  Culture:               cs-CZ
  UI culture:            en-US

Stack Trace:
at
Microsoft.CmdPal.UI.Settings.InternalPage.ThrowPlainMainThreadException_Click(Object
sender, RoutedEventArgs e)
at
WinRT._EventSource_global__Microsoft_UI_Xaml_RoutedEventHandler.EventState.b__1_0(Object
sender, RoutedEventArgs e)
at ABI.Microsoft.UI.Xaml.RoutedEventHandler.Do_Abi_Invoke(IntPtr
thisPtr, IntPtr sender, IntPtr e)

------------------ Full Exception Details ------------------
System.NotImplementedException: Test exception; thrown from the UI
thread
at
Microsoft.CmdPal.UI.Settings.InternalPage.ThrowPlainMainThreadException_Click(Object
sender, RoutedEventArgs e)
at
WinRT._EventSource_global__Microsoft_UI_Xaml_RoutedEventHandler.EventState.b__1_0(Object
sender, RoutedEventArgs e)
at ABI.Microsoft.UI.Xaml.RoutedEventHandler.Do_Abi_Invoke(IntPtr
thisPtr, IntPtr sender, IntPtr e)

============================================================

Real-world example: #41362 ### PII Sanitization Framework - `ErrorReportSanitizer`: Multi-layer sanitization pipeline for sensitive data. - Nine specialized rule providers: - `PiiRuleProvider`: Personally identifiable information (emails, phone numbers, SSNs). - `ProfilePathAndUsernameRuleProvider`: Windows user profiles and usernames. - `NetworkRuleProvider`: IP addresses, MAC addresses, network identifiers. - `SecretKeyValueRulesProvider`: API keys, tokens, passwords in key/value formats. - `FilenameMaskRuleProvider`: Sensitive file paths and extensions. - `UrlRuleProvider`: URLs and web addresses. - `TokenRuleProvider`: JWT and other auth tokens. - `ConnectionStringRuleProvider`: Database connection strings. - `EnvironmentPropertiesRuleProvider`: Environment variables and system properties. ### Internals Tools Page A page in settings available in non-CI-builds: image ## PR Checklist - [ ] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .../actions/spell-check/candidate.patterns | 2 +- .github/actions/spell-check/excludes.txt | 2 + .github/actions/spell-check/expect.txt | 4 +- PowerToys.slnx | 4 + src/modules/cmdpal/CommandPalette.slnf | 1 + .../Microsoft.CmdPal.Core.Common.csproj | 14 + .../Properties/Resources.Designer.cs | 76 +++++ .../Properties/Resources.resx | 127 +++++++++ .../Services/Reports/ErrorReportBuilder.cs | 118 ++++++++ .../Services/Reports/IErrorReportBuilder.cs | 33 +++ .../Sanitizer/Abstraction/ITextSanitizer.cs | 61 ++++ .../Sanitizer/Abstraction/SanitizationRule.cs | 36 +++ .../Sanitizer/ConnectionStringRuleProvider.cs | 20 ++ .../EnvironmentPropertiesRuleProvider.cs | 32 +++ .../Sanitizer/ErrorReportSanitizer.cs | 85 ++++++ .../Sanitizer/FilenameMaskRuleProvider.cs | 109 +++++++ .../Services/Sanitizer/GuardrailEventArgs.cs | 14 + .../Sanitizer/ISanitizationRuleProvider.cs | 12 + .../Services/Sanitizer/NetworkRuleProvider.cs | 84 ++++++ .../Services/Sanitizer/PiiRuleProvider.cs | 83 ++++++ .../ProfilePathAndUsernameRuleProvider.cs | 155 ++++++++++ .../Services/Sanitizer/SanitizerDefaults.cs | 13 + .../Sanitizer/SecretKeyValueRulesProvider.cs | 172 +++++++++++ .../Services/Sanitizer/TextSanitizer.cs | 135 +++++++++ .../Services/Sanitizer/TokenRuleProvider.cs | 25 ++ .../Services/Sanitizer/UrlRuleProvider.cs | 20 ++ src/modules/cmdpal/CoreCommonProps.props | 6 + .../Commands/OpenSettingsCommand.cs | 2 +- .../Messages/OpenSettingsMessage.cs | 4 +- .../cmdpal/Microsoft.CmdPal.UI/App.xaml.cs | 11 +- .../Controls/CommandBar.xaml.cs | 2 +- .../Controls/DevRibbon.xaml | 6 + .../Helpers/GlobalErrorHandler.cs | 57 +++- .../Helpers/TrayIconService.cs | 2 +- .../Microsoft.CmdPal.UI.csproj | 6 + .../Pages/ShellPage.xaml.cs | 5 +- .../Settings/InternalPage.SampleData.cs | 45 +++ .../Settings/InternalPage.xaml | 75 +++++ .../Settings/InternalPage.xaml.cs | 92 ++++++ .../Settings/SettingsWindow.xaml | 1 + .../Settings/SettingsWindow.xaml.cs | 60 +++- .../ViewModels/DevRibbonViewModel.cs | 8 + .../GlobalUsings.cs | 9 + ...rosoft.CmdPal.Core.Common.UnitTests.csproj | 25 ++ .../ConnectionStringRuleProviderTests.cs | 107 +++++++ .../ErrorReportSanitizerTests.TestData.cs | 81 ++++++ .../Sanitizer/ErrorReportSanitizerTests.cs | 25 ++ .../Sanitizer/PiiRuleProviderTests.cs | 122 ++++++++ .../SecretKeyValueRulesProviderTests.cs | 266 ++++++++++++++++++ .../TestUtils/SanitizerTestHelper.cs | 110 ++++++++ 50 files changed, 2529 insertions(+), 35 deletions(-) create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.resx create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/ErrorReportBuilder.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/IErrorReportBuilder.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/ITextSanitizer.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/SanitizationRule.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ConnectionStringRuleProvider.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/EnvironmentPropertiesRuleProvider.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ErrorReportSanitizer.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/GuardrailEventArgs.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ISanitizationRuleProvider.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ProfilePathAndUsernameRuleProvider.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SanitizerDefaults.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SecretKeyValueRulesProvider.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TextSanitizer.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TokenRuleProvider.cs create mode 100644 src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/UrlRuleProvider.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.SampleData.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.xaml create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/InternalPage.xaml.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/GlobalUsings.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Microsoft.CmdPal.Core.Common.UnitTests.csproj create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ConnectionStringRuleProviderTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.TestData.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/ErrorReportSanitizerTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/PiiRuleProviderTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Services/Sanitizer/SecretKeyValueRulesProviderTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/TestUtils/SanitizerTestHelper.cs diff --git a/.github/actions/spell-check/candidate.patterns b/.github/actions/spell-check/candidate.patterns index 7aa9c89c91..d530c32c7f 100644 --- a/.github/actions/spell-check/candidate.patterns +++ b/.github/actions/spell-check/candidate.patterns @@ -565,7 +565,7 @@ perl(?:\s+-[a-zA-Z]\w*)+ regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\) # regex choice -\(\?:[^)]+\|[^)]+\) +# \(\?:[^)]+\|[^)]+\) # proto ^\s*(\w+)\s\g{-1} = diff --git a/.github/actions/spell-check/excludes.txt b/.github/actions/spell-check/excludes.txt index 551c248923..3ffc4199f3 100644 --- a/.github/actions/spell-check/excludes.txt +++ b/.github/actions/spell-check/excludes.txt @@ -106,6 +106,8 @@ ^src/common/sysinternals/Eula/ ^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$ ^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$ +^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$ +^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/.*\.TestData\.cs$ ^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$ ^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/ ^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$ diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 3403ec130d..25c714a03f 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -597,6 +597,7 @@ frm FROMTOUCH fsanitize fsmgmt +ftps fuzzingtesting fxf FZE @@ -1329,7 +1330,7 @@ phwnd pici pidl PIDLIST -PII +pii pinfo pinvoke pipename @@ -1715,6 +1716,7 @@ srw srwlock sse ssf +Ssn sszzz STACKFRAME stackoverflow diff --git a/PowerToys.slnx b/PowerToys.slnx index 1f2a1fdbe9..8a166bb32e 100644 --- a/PowerToys.slnx +++ b/PowerToys.slnx @@ -300,6 +300,10 @@ + + + + diff --git a/src/modules/cmdpal/CommandPalette.slnf b/src/modules/cmdpal/CommandPalette.slnf index aa8f1165d9..6575a60790 100644 --- a/src/modules/cmdpal/CommandPalette.slnf +++ b/src/modules/cmdpal/CommandPalette.slnf @@ -15,6 +15,7 @@ "src\\modules\\cmdpal\\Microsoft.CmdPal.UI.ViewModels\\Microsoft.CmdPal.UI.ViewModels.csproj", "src\\modules\\cmdpal\\Microsoft.CmdPal.UI\\Microsoft.CmdPal.UI.csproj", "src\\modules\\cmdpal\\Microsoft.Terminal.UI\\Microsoft.Terminal.UI.vcxproj", + "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Core.Common.UnitTests\\Microsoft.CmdPal.Core.Common.UnitTests.csproj", "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Apps.UnitTests\\Microsoft.CmdPal.Ext.Apps.UnitTests.csproj", "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj", "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj", diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj index a6b270799c..300264967d 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Microsoft.CmdPal.Core.Common.csproj @@ -9,4 +9,18 @@ + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + + + diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..052da7deb1 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs @@ -0,0 +1,76 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.CmdPal.Core.Common.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Core.Common.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to This is an error report generated by Windows Command Palette. + ///If you are seeing this, it means something went a little sideways in the app. + ///You can help us fix it by filing a report at https://aka.ms/powerToysReportBug. + /// + ///(While you’re at it, give the details below a quick skim — just to make sure there’s nothing personal you’d prefer not to share. It’s rare, but sometimes little surprises sneak in.). + /// + internal static string ErrorReport_Global_Preamble { + get { + return ResourceManager.GetString("ErrorReport_Global_Preamble", resourceCulture); + } + } + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.resx b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.resx new file mode 100644 index 0000000000..e2aa867ad2 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.resx @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + This is an error report generated by Windows Command Palette. +If you are seeing this, it means something went a little sideways in the app. +You can help us fix it by filing a report at https://aka.ms/powerToysReportBug. + +(While you’re at it, give the details below a quick skim — just to make sure there’s nothing personal you’d prefer not to share. It’s rare, but sometimes little surprises sneak in.) + + \ No newline at end of file diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/ErrorReportBuilder.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/ErrorReportBuilder.cs new file mode 100644 index 0000000000..0c966f2593 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/ErrorReportBuilder.cs @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer; +using Windows.ApplicationModel; + +namespace Microsoft.CmdPal.Core.Common.Services.Reports; + +public sealed class ErrorReportBuilder : IErrorReportBuilder +{ + private readonly ErrorReportSanitizer _sanitizer = new(); + + private static string Preamble => Properties.Resources.ErrorReport_Global_Preamble; + + public string BuildReport(Exception exception, string context, bool redactPii = true) + { + ArgumentNullException.ThrowIfNull(exception); + + var exceptionMessage = CoalesceExceptionMessage(exception); + var sanitizedMessage = redactPii ? _sanitizer.Sanitize(exceptionMessage) : exceptionMessage; + var sanitizedFormattedException = redactPii ? _sanitizer.Sanitize(exception.ToString()) : exception.ToString(); + + // Note: + // - do not localize technical part of the report, we need to ensure it can be read by developers + // - keep timestamp format should be consistent with the log (makes it easier to search) + var technicalContent = + $""" + ============================================================ + Summary: + Message: {sanitizedMessage} + Type: {exception.GetType().FullName} + Source: {exception.Source ?? "N/A"} + Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss.fffffff} + HRESULT: 0x{exception.HResult:X8} ({exception.HResult}) + Context: {context ?? "N/A"} + + Application: + App version: {GetAppVersionSafe()} + Is elevated: {GetElevationStatus()} + + Environment: + OS version: {RuntimeInformation.OSDescription} + OS architecture: {RuntimeInformation.OSArchitecture} + Runtime identifier: {RuntimeInformation.RuntimeIdentifier} + Framework: {RuntimeInformation.FrameworkDescription} + Process architecture: {RuntimeInformation.ProcessArchitecture} + Culture: {CultureInfo.CurrentCulture.Name} + UI culture: {CultureInfo.CurrentUICulture.Name} + + Stack Trace: + {exception.StackTrace} + + ------------------ Full Exception Details ------------------ + {sanitizedFormattedException} + + ============================================================ + """; + + return $""" + {Preamble} + {technicalContent} + """; + } + + private static string GetElevationStatus() + { + // Note: do not localize technical part of the report, we need to ensure it can be read by developers + try + { + var isElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); + return isElevated ? "yes" : "no"; + } + catch (Exception) + { + return "Failed to determine elevation status"; + } + } + + private static string GetAppVersionSafe() + { + // Note: do not localize technical part of the report, we need to ensure it can be read by developers + try + { + var version = Package.Current.Id.Version; + return $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}"; + } + catch (Exception) + { + return "Failed to retrieve app version"; + } + } + + private static string CoalesceExceptionMessage(Exception exception) + { + // let's try to get a message from the exception or inferred it from the HRESULT + // to show at least something + var message = exception.Message; + if (string.IsNullOrWhiteSpace(message)) + { + var temp = Marshal.GetExceptionForHR(exception.HResult)?.Message; + if (!string.IsNullOrWhiteSpace(temp)) + { + message = temp + $" (inferred from HRESULT 0x{exception.HResult:X8})"; + } + } + + if (string.IsNullOrWhiteSpace(message)) + { + message = "No message available"; + } + + return message; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/IErrorReportBuilder.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/IErrorReportBuilder.cs new file mode 100644 index 0000000000..77487b01e5 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Reports/IErrorReportBuilder.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.Common.Services.Reports; + +/// +/// Defines a contract for creating human-readable error reports from exceptions, +/// suitable for logs, telemetry, or user-facing diagnostics. +/// +/// +/// Implementations should ensure reports are consistent and optionally redact +/// personally identifiable or sensitive information when requested. +/// +public interface IErrorReportBuilder +{ + /// + /// Builds a formatted error report for the specified and . + /// + /// The exception that triggered the error report. + /// + /// A short, human-readable description of where or what was being executed when the error occurred + /// (e.g., the operation name, component, or scenario). + /// + /// + /// When true, attempts to remove or obfuscate personally identifiable or sensitive information + /// (such as file paths, emails, machine/usernames, tokens). Defaults to true. + /// + /// + /// A formatted string containing the error report, suitable for logging or telemetry submission. + /// + string BuildReport(Exception exception, string context, bool redactPii = true); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/ITextSanitizer.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/ITextSanitizer.cs new file mode 100644 index 0000000000..85b7973bf9 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/ITextSanitizer.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +/// +/// Defines a service that sanitizes text by applying a set of configurable, regex-based rules. +/// Typical use cases include masking secrets, removing PII, or normalizing logs. +/// +/// +/// - Rules are applied in their registered order; rule ordering may affect the final output. +/// - Each rule should have a unique description that acts as its identifier. +/// +/// +public interface ITextSanitizer +{ + /// + /// Sanitizes the specified input by applying all registered rules in order. + /// + /// The input text to sanitize. Implementations should handle safely. + /// The sanitized text after all rules are applied. + string Sanitize(string? input); + + /// + /// Adds a sanitization rule using a .NET regular expression pattern and a replacement string. + /// + /// A .NET regular expression pattern used to match text to sanitize. + /// + /// The replacement text used by Regex.Replace. Supports standard regex replacement tokens, + /// including numbered groups ($1) and named groups (${name}). + /// + /// + /// A human-readable, unique identifier for the rule. Used to list, test, and remove the rule. + /// + /// + /// Implementations typically validate is a valid regex and may reject duplicate values. + /// + void AddRule(string pattern, string replacement, string description = ""); + + /// + /// Removes a previously added rule identified by its . + /// + /// The unique description of the rule to remove. + void RemoveRule(string description); + + /// + /// Gets a read-only snapshot of the currently registered sanitization rules in application order. + /// + /// A read-only list of items. + IReadOnlyList GetRules(); + + /// + /// Tests a single rule, identified by , against the provided , + /// without applying other rules. + /// + /// The input text to test. + /// The description (identifier) of the rule to test. + /// The result of applying only the specified rule to the input. + string TestRule(string input, string ruleDescription); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/SanitizationRule.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/SanitizationRule.cs new file mode 100644 index 0000000000..27460fafd5 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/Abstraction/SanitizationRule.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +public readonly record struct SanitizationRule +{ + public SanitizationRule(Regex regex, string replacement, string description = "") + { + Regex = regex; + Replacement = replacement; + Evaluator = null; + Description = description; + } + + public SanitizationRule(Regex regex, MatchEvaluator evaluator, string description = "") + { + Regex = regex; + Evaluator = evaluator; + Replacement = null; + Description = description; + } + + public Regex Regex { get; } + + public string? Replacement { get; } + + public MatchEvaluator? Evaluator { get; } + + public string Description { get; } + + public override string ToString() => $"{Description}: {Regex} -> {Replacement ?? ""}"; +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ConnectionStringRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ConnectionStringRuleProvider.cs new file mode 100644 index 0000000000..00fffbcb84 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ConnectionStringRuleProvider.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed partial class ConnectionStringRuleProvider : ISanitizationRuleProvider +{ + [GeneratedRegex(@"(Server|Data Source|Initial Catalog|Database|User ID|Username|Password|Pwd|Uid)\s*=\s*(?:""[^""]*""|'[^']*'|[^;,\s]+)", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex ConnectionParamRx(); + + public IEnumerable GetRules() + { + yield return new(ConnectionParamRx(), "$1=[REDACTED]", "Connection string parameters"); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/EnvironmentPropertiesRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/EnvironmentPropertiesRuleProvider.cs new file mode 100644 index 0000000000..4fcb779e35 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/EnvironmentPropertiesRuleProvider.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed class EnvironmentPropertiesRuleProvider : ISanitizationRuleProvider +{ + public IEnumerable GetRules() + { + List rules = []; + + var machine = Environment.MachineName; + if (!string.IsNullOrWhiteSpace(machine)) + { + var rx = new Regex(@"\b" + Regex.Escape(machine) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs)); + rules.Add(new(rx, "[MACHINE_NAME_REDACTED]", "Machine name")); + } + + var domain = Environment.UserDomainName; + if (!string.IsNullOrWhiteSpace(domain)) + { + var rx = new Regex(@"\b" + Regex.Escape(domain) + @"\b", SanitizerDefaults.DefaultOptions, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs)); + rules.Add(new(rx, "[USER_DOMAIN_NAME_REDACTED]", "User domain name")); + } + + return rules; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ErrorReportSanitizer.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ErrorReportSanitizer.cs new file mode 100644 index 0000000000..35c4496b28 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ErrorReportSanitizer.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +/// +/// Specific sanitizer used for error report content. Builds on top of the generic TextSanitizer. +/// +public sealed class ErrorReportSanitizer +{ + private readonly TextSanitizer _sanitizer = new(BuildProviders(), onGuardrailTriggered: OnGuardrailTriggered); + + private static void OnGuardrailTriggered(GuardrailEventArgs eventArgs) + { + var msg = $"Sanitization guardrail triggered for rule '{eventArgs.RuleDescription}': original length={eventArgs.OriginalLength}, result length={eventArgs.ResultLength}, ratio={eventArgs.Ratio:F2}, threshold={eventArgs.Threshold:F2}"; + CoreLogger.LogDebug(msg); + } + + private static IEnumerable BuildProviders() + { + // Order matters + return + [ + new PiiRuleProvider(), + new UrlRuleProvider(), + new NetworkRuleProvider(), + new TokenRuleProvider(), + new ConnectionStringRuleProvider(), + new SecretKeyValueRulesProvider(), + new EnvironmentPropertiesRuleProvider(), + new FilenameMaskRuleProvider(), + new ProfilePathAndUsernameRuleProvider() + ]; + } + + public string Sanitize(string? input) => _sanitizer.Sanitize(input); + + public string SanitizeException(Exception? exception) + { + if (exception is null) + { + return string.Empty; + } + + var fullMessage = GetFullExceptionMessage(exception); + return Sanitize(fullMessage); + } + + private static string GetFullExceptionMessage(Exception exception) + { + List messages = []; + var current = exception; + var depth = 0; + + // Prevent infinite loops on pathological InnerException graphs + while (current is not null && depth < 10) + { + messages.Add($"{current.GetType().Name}: {current.Message}"); + + if (!string.IsNullOrEmpty(current.StackTrace)) + { + messages.Add($"Stack Trace: {current.StackTrace}"); + } + + current = current.InnerException; + depth++; + } + + return string.Join(Environment.NewLine, messages); + } + + public void AddRule(string pattern, string replacement, string description = "") + => _sanitizer.AddRule(pattern, replacement, description); + + public void RemoveRule(string description) + => _sanitizer.RemoveRule(description); + + public IReadOnlyList GetRules() => _sanitizer.GetRules(); + + public string TestRule(string input, string ruleDescription) + => _sanitizer.TestRule(input, ruleDescription); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs new file mode 100644 index 0000000000..5356ddd90d --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/FilenameMaskRuleProvider.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Frozen; +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed class FilenameMaskRuleProvider : ISanitizationRuleProvider +{ + private static readonly FrozenSet CommonFileStemExclusions = new[] + { + "settings", + "config", + "configuration", + "appsettings", + "options", + "prefs", + "preferences", + "squirrel", + "app", + "system", + "env", + "environment", + "manifest", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public IEnumerable GetRules() + { + const string pattern = """ + (? + (?: [A-Za-z]: )? (?: [\\/][^\\/:*?""<>|\s]+ )+ # drive-rooted or UNC-like + | [^\\/:*?""<>|\s]+ (?: [\\/][^\\/:*?""<>|\s]+ )+ # relative with at least one sep + ) + """; + + var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs)); + yield return new SanitizationRule(rx, MatchEvaluator, "Mask filename in any path"); + yield break; + + static string MatchEvaluator(Match m) + { + var full = m.Groups["full"].Value; + + var lastSep = Math.Max(full.LastIndexOf('\\'), full.LastIndexOf('/')); + if (lastSep < 0 || lastSep == full.Length - 1) + { + return full; + } + + var dir = full[..(lastSep + 1)]; + var file = full[(lastSep + 1)..]; + + var dot = file.LastIndexOf('.'); + var looksLikeFile = (dot > 0 && dot < file.Length - 1) || (file.StartsWith('.') && file.Length > 1); + + if (!looksLikeFile) + { + return full; + } + + string stem, ext; + if (dot > 0 && dot < file.Length - 1) + { + stem = file[..dot]; + ext = file[dot..]; + } + else + { + stem = file; + ext = string.Empty; + } + + if (!ShouldMaskFileName(stem)) + { + return dir + file; + } + + var masked = MaskStem(stem) + ext; + return dir + masked; + } + } + + private static string NormalizeStem(string stem) + { + return stem.Replace("-", string.Empty, StringComparison.Ordinal) + .Replace("_", string.Empty, StringComparison.Ordinal) + .Replace(".", string.Empty, StringComparison.Ordinal); + } + + private static bool ShouldMaskFileName(string stem) + { + return !CommonFileStemExclusions.Contains(NormalizeStem(stem)); + } + + private static string MaskStem(string stem) + { + if (string.IsNullOrEmpty(stem)) + { + return stem; + } + + var keep = Math.Min(2, stem.Length); + var maskedCount = Math.Max(1, stem.Length - keep); + return stem[..keep] + new string('*', maskedCount); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/GuardrailEventArgs.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/GuardrailEventArgs.cs new file mode 100644 index 0000000000..ab00ac7510 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/GuardrailEventArgs.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +public record GuardrailEventArgs( + string RuleDescription, + int OriginalLength, + int ResultLength, + double Threshold) +{ + public double Ratio => OriginalLength > 0 ? (double)ResultLength / OriginalLength : 1.0; +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ISanitizationRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ISanitizationRuleProvider.cs new file mode 100644 index 0000000000..5d21c5262f --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ISanitizationRuleProvider.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal interface ISanitizationRuleProvider +{ + IEnumerable GetRules(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs new file mode 100644 index 0000000000..4c352ff892 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/NetworkRuleProvider.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed partial class NetworkRuleProvider : ISanitizationRuleProvider +{ + public IEnumerable GetRules() + { + yield return new(Ipv4Rx(), "[IP4_REDACTED]", "IP addresses"); + yield return new(Ipv6BracketedRx(), "[IP6_REDACTED]", "IPv6 addresses (bracketed/with port)"); + yield return new(Ipv6Rx(), "[IP6_REDACTED]", "IPv6 addresses"); + yield return new(MacAddressRx(), "[MAC_ADDRESS_REDACTED]", "MAC addresses"); + } + + [GeneratedRegex(@"\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex Ipv4Rx(); + + [GeneratedRegex( + """ + (?ix) # ignore case/whitespace + (?\d{1,5}) )? # optional port + """, + SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex Ipv6BracketedRx(); + + [GeneratedRegex(@"\b(?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2}|[0-9A-Fa-f]{1,2})\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex MacAddressRx(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs new file mode 100644 index 0000000000..964c6d83df --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/PiiRuleProvider.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed partial class PiiRuleProvider : ISanitizationRuleProvider +{ + public IEnumerable GetRules() + { + yield return new(EmailRx(), "[EMAIL_REDACTED]", "Email addresses"); + yield return new(SsnRx(), "[SSN_REDACTED]", "Social Security Numbers"); + yield return new(CreditCardRx(), "[CARD_REDACTED]", "Credit card numbers"); + + // phone number regex is the most generic, so it goes last + // we can't make this too generic; otherwise we over-redact error codes, dates, etc. + yield return new(PhoneRx(), "[PHONE_REDACTED]", "Phone numbers"); + } + + [GeneratedRegex(@"\b[a-zA-Z0-9]([a-zA-Z0-9._%-]*[a-zA-Z0-9])?@[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?\.[a-zA-Z]{2,}\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex EmailRx(); + + [GeneratedRegex(""" + (?xi) + # ---------- boundaries ---------- + (? require separators between blocks (avoid plain big ints) + (?:\(\d{1,4}\)|\d{1,4}) + (?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6} + ) + + # ---------- optional extension ---------- + (?:[\p{Zs}.\-,:;]* (?:ext\.?|x) [\p{Zs}]* (?\d{1,6}))? + + (?!-\w) # don't end just before '-letter'/'-digit' + """, + SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex PhoneRx(); + + [GeneratedRegex(@"\b\d{3}-\d{2}-\d{4}\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex SsnRx(); + + [GeneratedRegex(@"\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex CreditCardRx(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ProfilePathAndUsernameRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ProfilePathAndUsernameRuleProvider.cs new file mode 100644 index 0000000000..be3d086ae7 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/ProfilePathAndUsernameRuleProvider.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Frozen; +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed class ProfilePathAndUsernameRuleProvider : ISanitizationRuleProvider +{ + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs); + + private readonly Dictionary _profilePaths = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _usernames = new(StringComparer.OrdinalIgnoreCase); + + private static readonly FrozenSet CommonPathParts = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Users", "home", "Documents", "Desktop", "AppData", "Local", "Roaming", + "Pictures", "Videos", "Music", "Downloads", "Program Files", "Windows", + "System32", "bin", "usr", "var", "etc", "opt", "tmp", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + private static readonly FrozenSet CommonWords = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "admin", "user", "test", "guest", "public", "system", "service", + "default", "temp", "local", "shared", "common", "data", "config", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public ProfilePathAndUsernameRuleProvider() + { + DetectSystemPaths(); + } + + public IEnumerable GetRules() + { + List rules = []; + + // Profile path rules (ordered longest-first) + var orderedRules = _profilePaths + .Where(p => !string.IsNullOrEmpty(p.Key)) + .OrderByDescending(p => p.Key.Length); + + foreach (var profilePath in orderedRules) + { + try + { + var normalizedPath = profilePath.Key + .Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar); + var escapedPath = Regex.Escape(normalizedPath); + + var pattern = escapedPath + @"(?:[/\\]*)"; + var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout); + + rules.Add(new(rx, profilePath.Value, $"Profile path: {profilePath}")); + } + catch + { + // Skip problematic paths + } + } + + // Username rules + foreach (var username in _usernames.Where(u => !string.IsNullOrEmpty(u) && u.Length > 2)) + { + try + { + if (!IsLikelyUsername(username)) + { + continue; + } + + var rx = new Regex(@"\b" + Regex.Escape(username) + @"\b", SanitizerDefaults.DefaultOptions, DefaultTimeout); + rules.Add(new(rx, "[USERNAME_REDACTED]", $"Username: {username}")); + } + catch + { + // Skip problematic usernames + } + } + + return rules; + } + + public IReadOnlyDictionary GetDetectedProfilePaths() => _profilePaths; + + public IReadOnlyCollection GetDetectedUsernames() => _usernames; + + private void DetectSystemPaths() + { + try + { + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrEmpty(userProfile) && Directory.Exists(userProfile)) + { + _profilePaths.Add(userProfile, "[USER_PROFILE_DIR]"); + var username = Path.GetFileName(userProfile); + if (!string.IsNullOrEmpty(username) && username.Length > 2) + { + _usernames.Add(username); + } + } + + Environment.SpecialFolder[] profileFolders = + [ + Environment.SpecialFolder.ApplicationData, + Environment.SpecialFolder.LocalApplicationData, + Environment.SpecialFolder.Desktop, + Environment.SpecialFolder.MyDocuments, + Environment.SpecialFolder.MyPictures, + Environment.SpecialFolder.MyVideos, + Environment.SpecialFolder.MyMusic, + Environment.SpecialFolder.StartMenu, + Environment.SpecialFolder.Startup, + Environment.SpecialFolder.DesktopDirectory + ]; + + foreach (var folder in profileFolders) + { + var dir = Environment.GetFolderPath(folder); + if (string.IsNullOrEmpty(dir)) + { + continue; + } + + var added = _profilePaths.TryAdd(dir, $"[{folder.ToString().ToUpperInvariant()}_DIR]"); + if (!added) + { + continue; + } + } + + string[] envVars = ["USERPROFILE", "HOME", "OneDrive", "OneDriveCommercial"]; + foreach (var envVar in envVars) + { + var envPath = Environment.GetEnvironmentVariable(envVar); + if (!string.IsNullOrEmpty(envPath) && Directory.Exists(envPath)) + { + _profilePaths.TryAdd(envPath, $"[{envVar.ToUpperInvariant()}_DIR]"); + } + } + } + catch (Exception ex) + { + CoreLogger.LogError("Error detecting system profile paths and usernames", ex); + } + } + + private static bool IsLikelyUsername(string username) => + !CommonWords.Contains(username) && + username.Length is >= 3 and <= 50 && + !username.All(char.IsDigit); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SanitizerDefaults.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SanitizerDefaults.cs new file mode 100644 index 0000000000..83c7a9bbb1 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SanitizerDefaults.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal static class SanitizerDefaults +{ + public const RegexOptions DefaultOptions = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled; + public const int DefaultMatchTimeoutMs = 100; +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SecretKeyValueRulesProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SecretKeyValueRulesProvider.cs new file mode 100644 index 0000000000..d5b5f2358a --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/SecretKeyValueRulesProvider.cs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Frozen; +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed class SecretKeyValueRulesProvider : ISanitizationRuleProvider +{ + // Central list of common secret keys/phrases to redact when found in key=value pairs. + private static readonly FrozenSet SecretKeys = new HashSet(StringComparer.OrdinalIgnoreCase) + { + // Core passwords/secrets + "password", + "passphrase", + "passwd", + "pwd", + + // Tokens + "token", + "access token", + "refresh token", + "id token", + "auth token", + "session token", + "bearer token", + "personal access token", + "pat", + + // API / client credentials + "api key", + "api secret", + "x api key", + "client id", + "client secret", + "x client id", + "x client secret", + "consumer secret", + "service principal secret", + + // Cloud & platform (Azure/AppInsights/etc.) + "subscription key", + "instrumentation key", + "account key", + "storage account key", + "shared access key", + "shared access signature", + "SAS token", + + // Connection strings (often surfaced in exception messages) + "connection string", + "conn string", + "storage connection string", + + // Certificates & crypto + "private key", + "certificate password", + "client certificate password", + "pfx password", + + // AWS common keys + "aws access key id", + "aws secret access key", + "aws session token", + + // Optional service aliases + "cosmos db key", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + public IEnumerable GetRules() + { + yield return BuildSecretKeyValueRule( + SecretKeys, + timeout: TimeSpan.FromSeconds(5), + starEverything: true); + } + + private static SanitizationRule BuildSecretKeyValueRule( + IEnumerable keys, + RegexOptions? options = null, + TimeSpan? timeout = null, + string label = "[REDACTED]", + bool treatDashUnderscoreAsSpace = true, + string separatorsClass = "[:=]", // char class for separators + string unquotedStopClass = "\\s", + bool starEverything = false) + { + ArgumentNullException.ThrowIfNull(keys); + + // Between-word matcher for keys: "api key" -> "api\s*key" (optionally treating _/- as "space") + var between = treatDashUnderscoreAsSpace ? @"(?:\s|[_-])*" : @"\s*"; + + var patterns = new List(); + + foreach (var raw in keys) + { + var key = raw?.Trim(); + if (string.IsNullOrEmpty(key)) + { + continue; + } + + if (starEverything && key is not ['*', ..]) + { + key = "*" + key; + } + + if (key is ['*', .. var tail]) + { + // Wildcard prefix: allow one non-space token + optional "-" or "_" before the remainder. + // Matches: "api key", "api-key", "azure-api-key", "user_api_key" + var remainder = tail.Trim(); + if (remainder.Length == 0) + { + continue; + } + + var rem = Normalize(remainder, between); + patterns.Add($@"(?:(?>[A-Za-z0-9_]{{1,128}}[_-]))?{rem}"); + } + else + { + patterns.Add(Normalize(key, between)); + } + } + + if (patterns.Count == 0) + { + throw new ArgumentException("No non-empty keys provided.", nameof(keys)); + } + + var keysAlt = string.Join("|", patterns); + + var pattern = + $""" + # Negative lookbehind to ensure the key is not part of a larger word + (?(?:{keysAlt})) + # Negative lookahead to ensure the key is not part of a larger word + (?![A-Za-z0-9]) + # Optional whitespace between key and separator + \s* + # Separator (e.g., ':' or '=') + (?{separatorsClass}) + # Optional whitespace after separator + \s* + # Match and capture the value, supporting quoted or unquoted values + (?: + # Quoted value: match opening quote, value, and closing quote + (?["'])(?[^"']+)\k + | + # Unquoted value: match up to the next whitespace + (?[^{unquotedStopClass}]+) + ) + """; + + var rx = new Regex( + pattern, + (options ?? (RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) | RegexOptions.IgnorePatternWhitespace, + timeout ?? TimeSpan.FromMilliseconds(1000)); + + var replacement = @"${key}${sep} ${q}" + label + @"${q}"; + return new SanitizationRule(rx, replacement, "Sensitive key/value pairs"); + + static string Normalize(string s, string betweenSep) + => Regex.Escape(s).Replace("\\ ", betweenSep); + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TextSanitizer.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TextSanitizer.cs new file mode 100644 index 0000000000..7b835bc26f --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TextSanitizer.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +/// +/// Generic text sanitizer that applies a sequence of regex-based rules over input text. +/// +internal sealed class TextSanitizer : ITextSanitizer +{ + // Default guardrail: sanitized text must retain at least 30% of the original length + private const double DefaultGuardrailThreshold = 0.3; + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromMilliseconds(SanitizerDefaults.DefaultMatchTimeoutMs); + + private readonly List _rules = []; + private readonly double _guardrailThreshold; + private readonly Action? _onGuardrailTriggered; + + public TextSanitizer( + double guardrailThreshold = DefaultGuardrailThreshold, + Action? onGuardrailTriggered = null) + { + _guardrailThreshold = guardrailThreshold; + _onGuardrailTriggered = onGuardrailTriggered; + } + + public TextSanitizer( + IEnumerable providers, + double guardrailThreshold = DefaultGuardrailThreshold, + Action? onGuardrailTriggered = null) + { + ArgumentNullException.ThrowIfNull(providers); + _guardrailThreshold = guardrailThreshold; + _onGuardrailTriggered = onGuardrailTriggered; + + foreach (var p in providers) + { + try + { + _rules.AddRange(p.GetRules()); + } + catch + { + // Best-effort; ignore provider errors + } + } + } + + public string Sanitize(string? input) + { + if (string.IsNullOrEmpty(input)) + { + return input ?? string.Empty; + } + + var result = input; + + foreach (var rule in _rules) + { + try + { + var previous = result; + + result = rule.Evaluator is null + ? rule.Regex.Replace(previous, rule.Replacement!) + : rule.Regex.Replace(previous, rule.Evaluator); + + if (result.Length < previous.Length * _guardrailThreshold) + { + _onGuardrailTriggered?.Invoke(new GuardrailEventArgs( + rule.Description, + previous.Length, + result.Length, + _guardrailThreshold)); + result = previous; // Guardrail + } + } + catch (RegexMatchTimeoutException) + { + // Ignore timeouts; keep the original input + } + catch + { + // Ignore other exceptions; keep the original input + } + } + + return result; + } + + public void AddRule(string pattern, string replacement, string description = "") + { + var rx = new Regex(pattern, SanitizerDefaults.DefaultOptions, DefaultTimeout); + _rules.Add(new SanitizationRule(rx, replacement, description)); + } + + public void RemoveRule(string description) + { + _rules.RemoveAll(r => r.Description.Equals(description, StringComparison.OrdinalIgnoreCase)); + } + + public IReadOnlyList GetRules() => _rules.AsReadOnly(); + + public string TestRule(string input, string ruleDescription) + { + var rule = _rules.FirstOrDefault(r => r.Description.Contains(ruleDescription, StringComparison.OrdinalIgnoreCase)); + if (rule.Regex is null) + { + return input; + } + + try + { + if (rule.Evaluator is not null) + { + return rule.Regex.Replace(input, rule.Evaluator); + } + + if (rule.Replacement is not null) + { + return rule.Regex.Replace(input, rule.Replacement); + } + } + catch + { + // Ignore exceptions; return original input + } + + return input; + } +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TokenRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TokenRuleProvider.cs new file mode 100644 index 0000000000..fb8da33336 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/TokenRuleProvider.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed partial class TokenRuleProvider : ISanitizationRuleProvider +{ + public IEnumerable GetRules() + { + yield return new(JwtRx(), "[JWT_REDACTED]", "JSON Web Tokens (JWT)"); + yield return new(TokenRx(), "[TOKEN_REDACTED]", "Potential API keys/tokens"); + } + + [GeneratedRegex(@"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex JwtRx(); + + [GeneratedRegex(@"\b[A-Za-z0-9]{32,128}\b", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex TokenRx(); +} diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/UrlRuleProvider.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/UrlRuleProvider.cs new file mode 100644 index 0000000000..17ded73ea5 --- /dev/null +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Services/Sanitizer/UrlRuleProvider.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Text.RegularExpressions; +using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction; + +namespace Microsoft.CmdPal.Core.Common.Services.Sanitizer; + +internal sealed partial class UrlRuleProvider : ISanitizationRuleProvider +{ + public IEnumerable GetRules() + { + yield return new(UrlRx(), "[URL_REDACTED]", "URLs"); + } + + [GeneratedRegex(@"\b(?:https?|ftp|ftps|file|jdbc|ldap|mailto)://[^\s<>""'{}\[\]\\^`|]+", + SanitizerDefaults.DefaultOptions, SanitizerDefaults.DefaultMatchTimeoutMs)] + private static partial Regex UrlRx(); +} diff --git a/src/modules/cmdpal/CoreCommonProps.props b/src/modules/cmdpal/CoreCommonProps.props index 438d044e2a..51d502a65d 100644 --- a/src/modules/cmdpal/CoreCommonProps.props +++ b/src/modules/cmdpal/CoreCommonProps.props @@ -43,4 +43,10 @@ + + + <_Parameter1>$(AssemblyName).UnitTests + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/OpenSettingsCommand.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/OpenSettingsCommand.cs index ac7fe624e5..f0900ec72e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/OpenSettingsCommand.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/OpenSettingsCommand.cs @@ -19,7 +19,7 @@ public partial class OpenSettingsCommand : InvokableCommand public override ICommandResult Invoke() { - WeakReferenceMessenger.Default.Send(); + WeakReferenceMessenger.Default.Send(new OpenSettingsMessage()); return CommandResult.KeepOpen(); } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenSettingsMessage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenSettingsMessage.cs index c699ab427a..54909710a5 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenSettingsMessage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Messages/OpenSettingsMessage.cs @@ -4,6 +4,4 @@ namespace Microsoft.CmdPal.UI.Messages; -public record OpenSettingsMessage() -{ -} +public record OpenSettingsMessage(string SettingsPageTag = ""); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index a44682218f..09af333bbe 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -41,7 +41,7 @@ namespace Microsoft.CmdPal.UI; /// /// Provides application-specific behavior to supplement the default Application class. /// -public partial class App : Application +public partial class App : Application, IDisposable { private readonly GlobalErrorHandler _globalErrorHandler = new(); @@ -67,7 +67,7 @@ public partial class App : Application public App() { #if !CMDPAL_DISABLE_GLOBAL_ERROR_HANDLER - _globalErrorHandler.Register(this); + _globalErrorHandler.Register(this, GlobalErrorHandler.Options.Default); #endif Services = ConfigureServices(); @@ -203,4 +203,11 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); } + + public void Dispose() + { + _globalErrorHandler.Dispose(); + EtwTrace.Dispose(); + GC.SuppressFinalize(this); + } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs index 133a9364f0..b15e8d3c6e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/CommandBar.xaml.cs @@ -128,7 +128,7 @@ public sealed partial class CommandBar : UserControl, private void SettingsIcon_Clicked(object sender, RoutedEventArgs e) { - WeakReferenceMessenger.Default.Send(); + WeakReferenceMessenger.Default.Send(new OpenSettingsMessage()); } private void MoreCommandsButton_Clicked(object sender, RoutedEventArgs e) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml index e354f0519f..a4aa923f1b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml @@ -203,6 +203,12 @@ + + + + +