CmdPal: GEH per partes; part 1: error report builder, sanitizer and internals tools setting page (#44140)

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## 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.

<details><summary>Example</summary>
<pre>

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.<GetEventInvoke>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.<GetEventInvoke>b__1_0(Object
sender, RoutedEventArgs e)
at ABI.Microsoft.UI.Xaml.RoutedEventHandler.Do_Abi_Invoke(IntPtr
thisPtr, IntPtr sender, IntPtr e)

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

</pre>
</details> 

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:

<img width="1305" height="745" alt="image"
src="https://github.com/user-attachments/assets/3145ecfd-997f-491d-8c8a-6096634b6045"
/>


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **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

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
This commit is contained in:
Jiří Polášek
2026-01-29 04:09:37 +01:00
committed by GitHub
parent f82afdf384
commit 8ec530c65e
50 changed files with 2529 additions and 35 deletions

View File

@@ -565,7 +565,7 @@ perl(?:\s+-[a-zA-Z]\w*)+
regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\) regexp?\.MustCompile\((?:`[^`]*`|".*"|'.*')\)
# regex choice # regex choice
\(\?:[^)]+\|[^)]+\) # \(\?:[^)]+\|[^)]+\)
# proto # proto
^\s*(\w+)\s\g{-1} = ^\s*(\w+)\s\g{-1} =

View File

@@ -106,6 +106,8 @@
^src/common/sysinternals/Eula/ ^src/common/sysinternals/Eula/
^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$ ^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$ ^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/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/ ^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$ ^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$

View File

@@ -597,6 +597,7 @@ frm
FROMTOUCH FROMTOUCH
fsanitize fsanitize
fsmgmt fsmgmt
ftps
fuzzingtesting fuzzingtesting
fxf fxf
FZE FZE
@@ -1329,7 +1330,7 @@ phwnd
pici pici
pidl pidl
PIDLIST PIDLIST
PII pii
pinfo pinfo
pinvoke pinvoke
pipename pipename
@@ -1715,6 +1716,7 @@ srw
srwlock srwlock
sse sse
ssf ssf
Ssn
sszzz sszzz
STACKFRAME STACKFRAME
stackoverflow stackoverflow

View File

@@ -300,6 +300,10 @@
</Project> </Project>
</Folder> </Folder>
<Folder Name="/modules/CommandPalette/Tests/"> <Folder Name="/modules/CommandPalette/Tests/">
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Core.Common.UnitTests/Microsoft.CmdPal.Core.Common.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj"> <Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Apps.UnitTests/Microsoft.CmdPal.Ext.Apps.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" /> <Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" /> <Platform Solution="*|x64" Project="x64" />

View File

@@ -15,6 +15,7 @@
"src\\modules\\cmdpal\\Microsoft.CmdPal.UI.ViewModels\\Microsoft.CmdPal.UI.ViewModels.csproj", "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.CmdPal.UI\\Microsoft.CmdPal.UI.csproj",
"src\\modules\\cmdpal\\Microsoft.Terminal.UI\\Microsoft.Terminal.UI.vcxproj", "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.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.Bookmarks.UnitTests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj", "src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj",

View File

@@ -9,4 +9,18 @@
<PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
</EmbeddedResource>
</ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,76 @@
//------------------------------------------------------------------------------
// <auto-generated>
// 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.
// </auto-generated>
//------------------------------------------------------------------------------
namespace Microsoft.CmdPal.Core.Common.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// 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() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[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;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// 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 youre at it, give the details below a quick skim — just to make sure theres nothing personal youd prefer not to share. Its rare, but sometimes little surprises sneak in.).
/// </summary>
internal static string ErrorReport_Global_Preamble {
get {
return ResourceManager.GetString("ErrorReport_Global_Preamble", resourceCulture);
}
}
}
}

View File

@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ErrorReport_Global_Preamble" xml:space="preserve">
<value>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 youre at it, give the details below a quick skim — just to make sure theres nothing personal youd prefer not to share. Its rare, but sometimes little surprises sneak in.)</value>
</data>
</root>

View File

@@ -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;
}
}

View File

@@ -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;
/// <summary>
/// Defines a contract for creating human-readable error reports from exceptions,
/// suitable for logs, telemetry, or user-facing diagnostics.
/// </summary>
/// <remarks>
/// Implementations should ensure reports are consistent and optionally redact
/// personally identifiable or sensitive information when requested.
/// </remarks>
public interface IErrorReportBuilder
{
/// <summary>
/// Builds a formatted error report for the specified <paramref name="exception"/> and <paramref name="context"/>.
/// </summary>
/// <param name="exception">The exception that triggered the error report.</param>
/// <param name="context">
/// A short, human-readable description of where or what was being executed when the error occurred
/// (e.g., the operation name, component, or scenario).
/// </param>
/// <param name="redactPii">
/// When true, attempts to remove or obfuscate personally identifiable or sensitive information
/// (such as file paths, emails, machine/usernames, tokens). Defaults to true.
/// </param>
/// <returns>
/// A formatted string containing the error report, suitable for logging or telemetry submission.
/// </returns>
string BuildReport(Exception exception, string context, bool redactPii = true);
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// - Rules are applied in their registered order; rule ordering may affect the final output.
/// - Each rule should have a unique <c>description</c> that acts as its identifier.
/// </remarks>
/// <seealso cref="SanitizationRule"/>
public interface ITextSanitizer
{
/// <summary>
/// Sanitizes the specified input by applying all registered rules in order.
/// </summary>
/// <param name="input">The input text to sanitize. Implementations should handle <see langword="null"/> safely.</param>
/// <returns>The sanitized text after all rules are applied.</returns>
string Sanitize(string? input);
/// <summary>
/// Adds a sanitization rule using a .NET regular expression pattern and a replacement string.
/// </summary>
/// <param name="pattern">A .NET regular expression pattern used to match text to sanitize.</param>
/// <param name="replacement">
/// The replacement text used by <c>Regex.Replace</c>. Supports standard regex replacement tokens,
/// including numbered groups (<c>$1</c>) and named groups (<c>${name}</c>).
/// </param>
/// <param name="description">
/// A human-readable, unique identifier for the rule. Used to list, test, and remove the rule.
/// </param>
/// <remarks>
/// Implementations typically validate <paramref name="pattern"/> is a valid regex and may reject duplicate <paramref name="description"/> values.
/// </remarks>
void AddRule(string pattern, string replacement, string description = "");
/// <summary>
/// Removes a previously added rule identified by its <paramref name="description"/>.
/// </summary>
/// <param name="description">The unique description of the rule to remove.</param>
void RemoveRule(string description);
/// <summary>
/// Gets a read-only snapshot of the currently registered sanitization rules in application order.
/// </summary>
/// <returns>A read-only list of <see cref="SanitizationRule"/> items.</returns>
IReadOnlyList<SanitizationRule> GetRules();
/// <summary>
/// Tests a single rule, identified by <paramref name="ruleDescription"/>, against the provided <paramref name="input"/>,
/// without applying other rules.
/// </summary>
/// <param name="input">The input text to test.</param>
/// <param name="ruleDescription">The description (identifier) of the rule to test.</param>
/// <returns>The result of applying only the specified rule to the input.</returns>
string TestRule(string input, string ruleDescription);
}

View File

@@ -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 ?? "<evaluator>"}";
}

View File

@@ -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<SanitizationRule> GetRules()
{
yield return new(ConnectionParamRx(), "$1=[REDACTED]", "Connection string parameters");
}
}

View File

@@ -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<SanitizationRule> GetRules()
{
List<SanitizationRule> 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;
}
}

View File

@@ -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;
/// <summary>
/// Specific sanitizer used for error report content. Builds on top of the generic TextSanitizer.
/// </summary>
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<ISanitizationRuleProvider> 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<string> 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<SanitizationRule> GetRules() => _sanitizer.GetRules();
public string TestRule(string input, string ruleDescription)
=> _sanitizer.TestRule(input, ruleDescription);
}

View File

@@ -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<string> CommonFileStemExclusions = new[]
{
"settings",
"config",
"configuration",
"appsettings",
"options",
"prefs",
"preferences",
"squirrel",
"app",
"system",
"env",
"environment",
"manifest",
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
public IEnumerable<SanitizationRule> GetRules()
{
const string pattern = """
(?<full>
(?: [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);
}
}

View File

@@ -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;
}

View File

@@ -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<SanitizationRule> GetRules();
}

View File

@@ -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<SanitizationRule> 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
(?<![A-F0-9:]) # left edge
(
(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} | # 1:2:3:4:5:6:7:8
(?:[A-F0-9]{1,4}:){1,7}: | # 1:: 1:2:...:7::
(?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} |
(?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} |
(?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} |
(?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} |
(?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} |
[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} |
:(?::[A-F0-9]{1,4}){1,7} | # ::, ::1, etc.
(?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} | # IPv4 tail
(?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} |
[A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} |
:(?:\d{1,3}\.){3}\d{1,3}
)
(?:%\w+)? # optional zone id
(?![A-F0-9:]) # right edge
""",
SanitizerDefaults.DefaultOptions | RegexOptions.IgnorePatternWhitespace, SanitizerDefaults.DefaultMatchTimeoutMs)]
private static partial Regex Ipv6Rx();
[GeneratedRegex(
"""
(?ix)
\[
(
(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4} |
(?:[A-F0-9]{1,4}:){1,7}: |
(?:[A-F0-9]{1,4}:){1,6}:[A-F0-9]{1,4} |
(?:[A-F0-9]{1,4}:){1,5}(?::[A-F0-9]{1,4}){1,2} |
(?:[A-F0-9]{1,4}:){1,4}(?::[A-F0-9]{1,4}){1,3} |
(?:[A-F0-9]{1,4}:){1,3}(?::[A-F0-9]{1,4}){1,4} |
(?:[A-F0-9]{1,4}:){1,2}(?::[A-F0-9]{1,4}){1,5} |
[A-F0-9]{1,4}:(?::[A-F0-9]{1,4}){1,6} |
:(?::[A-F0-9]{1,4}){1,7} |
(?:[A-F0-9]{1,4}:){6}\d{1,3}(?:\.\d{1,3}){3} |
(?:[A-F0-9]{1,4}:){1,5}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,4}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,3}:(?:\d{1,3}\.){3}\d{1,3} |
(?:[A-F0-9]{1,4}:){1,2}:(?:\d{1,3}\.){3}\d{1,3} |
[A-F0-9]{1,4}:(?:\d{1,3}\.){3}\d{1,3} |
:(?:\d{1,3}\.){3}\d{1,3}
)
(?:%\w+)? # optional zone id
\]
(?: : (?<port>\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();
}

View File

@@ -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<SanitizationRule> 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 ----------
(?<!\w) # not after a letter/digit/underscore
(?<![A-Za-z0-9]-) # avoid starting inside hyphenated tokens (GUID middles, etc.)
# ---------- global do-not-match guards ----------
(?! # ISO date (yyyy-mm-dd / yyyy.mm.dd / yyyy/mm/dd)
(?:19|20)\d{2}[-./](?:0[1-9]|1[0-2])[-./](?:0[1-9]|[12]\d|3[01])\b
)
(?! # EU date (dd-mm-yyyy / dd.mm.yyyy / dd/mm/yyyy)
(?:0[1-9]|[12]\d|3[01])[-./](?:0[1-9]|1[0-2])[-./](?:19|20)\d{2}\b
)
(?! # ISO datetime like 2025-08-24T14:32[:ss][Z|±hh:mm]
(?:19|20)\d{2}-\d{2}-\d{2}[T\s]\d{2}:\d{2}(?::\d{2})?(?:Z|[+-]\d{2}:\d{2})?\b
)
(?!\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?\b) # IPv4 with optional :port
(?!\b[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}\b) # GUID, lowercase
(?!\b[0-9A-F]{8}-(?:[0-9A-F]{4}-){3}[0-9A-F]{12}\b) # GUID, uppercase
(?!\bv?\d+(?:\.\d+){2,}\b) # semantic/file versions like 1.2.3 or 10.0.22631.3448
(?!\b(?:[0-9A-F]{2}[:-]){5}[0-9A-F]{2}\b) # MAC address
# ---------- digit budget ----------
(?=(?:\D*\d){7,15}) # 715 digits in total
# ---------- number body ----------
(?:
# A with explicit country code, allow compact digits (E.164-ish) or grouped
(?:\+|00)[1-9]\d{0,2}
(?:
[\p{Zs}.\-\/]*\d{6,14}
|
[\p{Zs}.\-\/]* (?:\(\d{1,4}\)|\d{1,4})
(?:[\p{Zs}.\-\/]+(?:\(\d{2,4}\)|\d{2,4})){1,6}
)
|
# B no country code => 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}]* (?<ext>\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();
}

View File

@@ -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<string, string> _profilePaths = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _usernames = new(StringComparer.OrdinalIgnoreCase);
private static readonly FrozenSet<string> CommonPathParts = new HashSet<string>(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<string> CommonWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"admin", "user", "test", "guest", "public", "system", "service",
"default", "temp", "local", "shared", "common", "data", "config",
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
public ProfilePathAndUsernameRuleProvider()
{
DetectSystemPaths();
}
public IEnumerable<SanitizationRule> GetRules()
{
List<SanitizationRule> 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<string, string> GetDetectedProfilePaths() => _profilePaths;
public IReadOnlyCollection<string> 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);
}

View File

@@ -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;
}

View File

@@ -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<string> SecretKeys = new HashSet<string>(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<SanitizationRule> GetRules()
{
yield return BuildSecretKeyValueRule(
SecretKeys,
timeout: TimeSpan.FromSeconds(5),
starEverything: true);
}
private static SanitizationRule BuildSecretKeyValueRule(
IEnumerable<string> 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<string>();
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
(?<![A-Za-z0-9])
# Match and capture the key (from the provided list)
(?<key>(?:{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 '=')
(?<sep>{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
(?<q>["'])(?<val>[^"']+)\k<q>
|
# Unquoted value: match up to the next whitespace
(?<val>[^{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);
}
}

View File

@@ -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;
/// <summary>
/// Generic text sanitizer that applies a sequence of regex-based rules over input text.
/// </summary>
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<SanitizationRule> _rules = [];
private readonly double _guardrailThreshold;
private readonly Action<GuardrailEventArgs>? _onGuardrailTriggered;
public TextSanitizer(
double guardrailThreshold = DefaultGuardrailThreshold,
Action<GuardrailEventArgs>? onGuardrailTriggered = null)
{
_guardrailThreshold = guardrailThreshold;
_onGuardrailTriggered = onGuardrailTriggered;
}
public TextSanitizer(
IEnumerable<ISanitizationRuleProvider> providers,
double guardrailThreshold = DefaultGuardrailThreshold,
Action<GuardrailEventArgs>? 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<SanitizationRule> 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;
}
}

View File

@@ -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<SanitizationRule> 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();
}

View File

@@ -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<SanitizationRule> 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();
}

View File

@@ -43,4 +43,10 @@
</EmbeddedResource> </EmbeddedResource>
</ItemGroup> </ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>$(AssemblyName).UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project> </Project>

View File

@@ -19,7 +19,7 @@ public partial class OpenSettingsCommand : InvokableCommand
public override ICommandResult Invoke() public override ICommandResult Invoke()
{ {
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>(); WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
return CommandResult.KeepOpen(); return CommandResult.KeepOpen();
} }
} }

View File

@@ -4,6 +4,4 @@
namespace Microsoft.CmdPal.UI.Messages; namespace Microsoft.CmdPal.UI.Messages;
public record OpenSettingsMessage() public record OpenSettingsMessage(string SettingsPageTag = "");
{
}

View File

@@ -41,7 +41,7 @@ namespace Microsoft.CmdPal.UI;
/// <summary> /// <summary>
/// Provides application-specific behavior to supplement the default Application class. /// Provides application-specific behavior to supplement the default Application class.
/// </summary> /// </summary>
public partial class App : Application public partial class App : Application, IDisposable
{ {
private readonly GlobalErrorHandler _globalErrorHandler = new(); private readonly GlobalErrorHandler _globalErrorHandler = new();
@@ -67,7 +67,7 @@ public partial class App : Application
public App() public App()
{ {
#if !CMDPAL_DISABLE_GLOBAL_ERROR_HANDLER #if !CMDPAL_DISABLE_GLOBAL_ERROR_HANDLER
_globalErrorHandler.Register(this); _globalErrorHandler.Register(this, GlobalErrorHandler.Options.Default);
#endif #endif
Services = ConfigureServices(); Services = ConfigureServices();
@@ -203,4 +203,11 @@ public partial class App : Application
services.AddSingleton<ShellViewModel>(); services.AddSingleton<ShellViewModel>();
services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>(); services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>();
} }
public void Dispose()
{
_globalErrorHandler.Dispose();
EtwTrace.Dispose();
GC.SuppressFinalize(this);
}
} }

View File

@@ -128,7 +128,7 @@ public sealed partial class CommandBar : UserControl,
private void SettingsIcon_Clicked(object sender, RoutedEventArgs e) private void SettingsIcon_Clicked(object sender, RoutedEventArgs e)
{ {
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>(); WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
} }
private void MoreCommandsButton_Clicked(object sender, RoutedEventArgs e) private void MoreCommandsButton_Clicked(object sender, RoutedEventArgs e)

View File

@@ -203,6 +203,12 @@
</Grid> </Grid>
</Border> </Border>
<!-- More section -->
<TextBlock Style="{ThemeResource SettingsSectionHeaderTextBlockStyle}" Text="More" />
<Border>
<Button Command="{x:Bind ViewModel.OpenInternalToolsCommand}" Content="Open internal tools" />
</Border>
</StackPanel> </StackPanel>
<!-- Footer --> <!-- Footer -->

View File

@@ -3,7 +3,7 @@
// See the LICENSE file in the project root for more information. // See the LICENSE file in the project root for more information.
using ManagedCommon; using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CmdPal.Core.Common.Services.Reports;
using Windows.Win32; using Windows.Win32;
using Windows.Win32.Foundation; using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging; using Windows.Win32.UI.WindowsAndMessaging;
@@ -15,14 +15,22 @@ namespace Microsoft.CmdPal.UI.Helpers;
/// <summary> /// <summary>
/// Global error handler for Command Palette. /// Global error handler for Command Palette.
/// </summary> /// </summary>
internal sealed partial class GlobalErrorHandler internal sealed partial class GlobalErrorHandler : IDisposable
{ {
private readonly ErrorReportBuilder _errorReportBuilder = new();
private Options? _options;
private App? _app;
// GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available. // GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available.
internal void Register(App app) internal void Register(App app, Options options)
{ {
ArgumentNullException.ThrowIfNull(app); ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(options);
app.UnhandledException += App_UnhandledException; _options = options;
_app = app;
_app.UnhandledException += App_UnhandledException;
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException; TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
} }
@@ -54,21 +62,15 @@ internal sealed partial class GlobalErrorHandler
HandleException(e.Exception, Context.UnobservedTaskException); HandleException(e.Exception, Context.UnobservedTaskException);
} }
private static void HandleException(Exception ex, Context context) private void HandleException(Exception ex, Context context)
{ {
Logger.LogError($"Unhandled exception detected ({context})", ex); Logger.LogError($"Unhandled exception detected ({context})", ex);
if (context == Context.MainThreadException) if (context == Context.MainThreadException)
{ {
var error = DiagnosticsHelper.BuildExceptionMessage(ex, null); var report = _errorReportBuilder.BuildReport(ex, context.ToString(), _options?.RedactPii ?? true);
var report = $"""
This is an error report generated by Windows Command Palette.
If you are seeing this message, it means the application has encountered an unexpected issue.
You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
{error}
""";
StoreReport(report, storeOnDesktop: false); StoreReport(report, storeOnDesktop: _options?.StoreReportOnUserDesktop == true);
string message; string message;
string caption; string caption;
@@ -138,6 +140,13 @@ internal sealed partial class GlobalErrorHandler
} }
} }
public void Dispose()
{
_app?.UnhandledException -= App_UnhandledException;
TaskScheduler.UnobservedTaskException -= TaskScheduler_UnobservedTaskException;
AppDomain.CurrentDomain.UnhandledException -= CurrentDomain_UnhandledException;
}
private enum Context private enum Context
{ {
Unknown = 0, Unknown = 0,
@@ -146,4 +155,26 @@ internal sealed partial class GlobalErrorHandler
UnobservedTaskException, UnobservedTaskException,
AppDomainUnhandledException, AppDomainUnhandledException,
} }
/// <summary>
/// Configuration options controlling how <see cref="GlobalErrorHandler"/> reacts to exceptions
/// (what to log, what to show to the user, and where to store reports).
/// </summary>
internal sealed record Options
{
/// <summary>
/// Gets the default configuration.
/// </summary>
public static Options Default { get; } = new();
/// <summary>
/// Gets a value indicating whether Personally Identifiable Information (PII) should be redacted in error reports.
/// </summary>
public bool RedactPii { get; init; } = true;
/// <summary>
/// Gets a value indicating whether to store the error report on the user's desktop in addition to the log directory.
/// </summary>
public bool StoreReportOnUserDesktop { get; init; }
}
} }

View File

@@ -152,7 +152,7 @@ internal sealed partial class TrayIconService
{ {
if (wParam == PInvoke.WM_USER + 1) if (wParam == PInvoke.WM_USER + 1)
{ {
WeakReferenceMessenger.Default.Send<OpenSettingsMessage>(); WeakReferenceMessenger.Default.Send(new OpenSettingsMessage());
} }
else if (wParam == PInvoke.WM_USER + 2) else if (wParam == PInvoke.WM_USER + 2)
{ {

View File

@@ -83,6 +83,7 @@
<None Remove="Pages\Settings\GeneralPage.xaml" /> <None Remove="Pages\Settings\GeneralPage.xaml" />
<None Remove="SettingsWindow.xaml" /> <None Remove="SettingsWindow.xaml" />
<None Remove="Settings\AppearancePage.xaml" /> <None Remove="Settings\AppearancePage.xaml" />
<None Remove="Settings\InternalPage.xaml" />
<None Remove="ShellPage.xaml" /> <None Remove="ShellPage.xaml" />
<None Remove="Styles\Colors.xaml" /> <None Remove="Styles\Colors.xaml" />
<None Remove="Styles\Settings.xaml" /> <None Remove="Styles\Settings.xaml" />
@@ -264,6 +265,11 @@
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>
</Page> </Page>
</ItemGroup> </ItemGroup>
<ItemGroup>
<Page Update="Settings\InternalPage.xaml">
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup> <ItemGroup>
<Page Update="Styles\Colors.xaml"> <Page Update="Styles\Colors.xaml">
<Generator>MSBuild:Compile</Generator> <Generator>MSBuild:Compile</Generator>

View File

@@ -257,11 +257,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
{ {
_ = DispatcherQueue.TryEnqueue(() => _ = DispatcherQueue.TryEnqueue(() =>
{ {
OpenSettings(); OpenSettings(message.SettingsPageTag);
}); });
} }
public void OpenSettings() public void OpenSettings(string pageTag)
{ {
if (_settingsWindow is null) if (_settingsWindow is null)
{ {
@@ -270,6 +270,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_settingsWindow.Activate(); _settingsWindow.Activate();
_settingsWindow.BringToFront(); _settingsWindow.BringToFront();
_settingsWindow.Navigate(pageTag);
} }
public void Receive(ShowDetailsMessage message) public void Receive(ShowDetailsMessage message)

View File

@@ -0,0 +1,45 @@
// 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.UI.Settings;
public partial class InternalPage
{
internal static class SampleData
{
internal static string ExceptionMessageWithPii { get; } =
$"""
Test exception with personal information; thrown from the UI thread
Here is e-mail address <jane.doe@contoso.com>
IPv4 address: 192.168.100.1
IPv4 loopback address: 127.0.0.1
MAC address: 00-14-22-01-23-45
IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
IPv6 loopback address: ::1
Password: P@ssw0rd123!
Password=secret
Api key: 1234567890abcdef
PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb
InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com;
X-API-key: 1234567890abcdef
Pet-Shop-Subscription-Key: 1234567890abcdef
Here is a user name {Environment.UserName}
And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\Pictures
Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal
Here is machine name {Environment.MachineName}
JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
User email john.doe@company.com failed validation
File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt
Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test
Phone number 555-123-4567 is invalid
API key abc123def456ghi789jkl012mno345pqr678 expired
Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123
Error accessing file://C:/Users/john.doe/Documents/confidential.pdf
JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret
FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv
Email service error: mailto:admin@internal-company.com?subject=Alert
""";
}
}

View File

@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="Microsoft.CmdPal.UI.Settings.InternalPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="using:CommunityToolkit.WinUI"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="1">
<Grid Padding="16">
<StackPanel
MaxWidth="1000"
HorizontalAlignment="Stretch"
Spacing="{StaticResource SettingsCardSpacing}">
<TextBlock Style="{StaticResource BodyTextBlockStyle}" Text="Tools on this page are for internal use only. This page is not visible in CI builds." />
<!-- Exception Handling Section -->
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Exception Handling" />
<controls:SettingsExpander
Description="Actions for testing global exception handling from the application"
Header="Throw exceptions"
HeaderIcon="{ui:FontIcon Glyph=&#xE783;}"
IsExpanded="True">
<controls:SettingsExpander.Items>
<controls:SettingsCard Header="Throw an unhandled exception from the UI thread">
<Button Click="ThrowPlainMainThreadException_Click" Content="Throw" />
</controls:SettingsCard>
<controls:SettingsCard Header="Throw an unhandled exception from the UI thread (with PII)">
<Button Click="ThrowPlainMainThreadExceptionPii_Click" Content="Throw" />
</controls:SettingsCard>
<controls:SettingsCard Description="Throw with delay, when the task is collected by the GC" Header="Throw unobserved exception from a task">
<Button Click="ThrowExceptionInUnobservedTask_Click" Content="Throw" />
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
<!-- Diagnostics Section -->
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Diagnostics" />
<controls:SettingsCard
x:Name="LogsSettingsCard"
Header="Logs folder"
HeaderIcon="{ui:FontIcon Glyph=&#xE8B7;}">
<Button Click="OpenLogsCardClicked" Content="Open folder" />
</controls:SettingsCard>
<controls:SettingsCard
x:Name="CurrentLogFileSettingsCard"
Header="Current log file"
HeaderIcon="{ui:FontIcon Glyph=&#xF7BB;}">
<Button Click="OpenCurrentLogCardClicked" Content="Open log" />
</controls:SettingsCard>
<!-- Data Section -->
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Data and Files" />
<controls:SettingsCard
x:Name="ConfigurationFolderSettingsCard"
Header="Configuration folder"
HeaderIcon="{ui:FontIcon Glyph=&#xF73D;}">
<Button Click="OpenConfigFolderCardClick" Content="Open folder" />
</controls:SettingsCard>
</StackPanel>
</Grid>
</ScrollViewer>
</Grid>
</Page>

View File

@@ -0,0 +1,92 @@
// 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 ManagedCommon;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.UI.Xaml;
using Windows.System;
using Page = Microsoft.UI.Xaml.Controls.Page;
namespace Microsoft.CmdPal.UI.Settings;
/// <summary>
/// An empty page that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class InternalPage : Page
{
public InternalPage()
{
InitializeComponent();
}
private void ThrowPlainMainThreadException_Click(object sender, RoutedEventArgs e)
{
Logger.LogDebug("Throwing test exception from the UI thread");
throw new NotImplementedException("Test exception; thrown from the UI thread");
}
private void ThrowExceptionInUnobservedTask_Click(object sender, RoutedEventArgs e)
{
Logger.LogDebug("Starting a task that will throw test exception");
Task.Run(() =>
{
Logger.LogDebug("Throwing test exception from a task");
throw new InvalidOperationException("Test exception; thrown from a task");
});
}
private void ThrowPlainMainThreadExceptionPii_Click(object sender, RoutedEventArgs e)
{
Logger.LogDebug("Throwing test exception from the UI thread (PII)");
throw new InvalidOperationException(SampleData.ExceptionMessageWithPii);
}
private async void OpenLogsCardClicked(object sender, RoutedEventArgs e)
{
try
{
var logFolderPath = Logger.CurrentVersionLogDirectoryPath;
if (Directory.Exists(logFolderPath))
{
await Launcher.LaunchFolderPathAsync(logFolderPath);
}
}
catch (Exception ex)
{
Logger.LogError("Failed to open directory in Explorer", ex);
}
}
private async void OpenCurrentLogCardClicked(object sender, RoutedEventArgs e)
{
try
{
var logPath = Logger.CurrentLogFile;
if (File.Exists(logPath))
{
await Launcher.LaunchUriAsync(new Uri(logPath));
}
}
catch (Exception ex)
{
Logger.LogError("Failed to open log file", ex);
}
}
private async void OpenConfigFolderCardClick(object sender, RoutedEventArgs e)
{
try
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
if (Directory.Exists(directory))
{
await Launcher.LaunchFolderPathAsync(directory);
}
}
catch (Exception ex)
{
Logger.LogError("Failed to open directory in Explorer", ex);
}
}
}

View File

@@ -72,6 +72,7 @@
x:Uid="Settings_GeneralPage_NavigationViewItem_Extensions" x:Uid="Settings_GeneralPage_NavigationViewItem_Extensions"
Icon="{ui:FontIcon Glyph=&#xEA86;}" Icon="{ui:FontIcon Glyph=&#xEA86;}"
Tag="Extensions" /> Tag="Extensions" />
<!-- "Internal Tools" page item is added dynamically from code -->
</NavigationView.MenuItems> </NavigationView.MenuItems>
<Grid> <Grid>
<Grid.RowDefinitions> <Grid.RowDefinitions>

View File

@@ -30,6 +30,8 @@ public sealed partial class SettingsWindow : WindowEx,
{ {
private readonly LocalKeyboardListener _localKeyboardListener; private readonly LocalKeyboardListener _localKeyboardListener;
private readonly NavigationViewItem? _internalNavItem;
public ObservableCollection<Crumb> BreadCrumbs { get; } = []; public ObservableCollection<Crumb> BreadCrumbs { get; } = [];
// Gets or sets optional action invoked after NavigationView is loaded. // Gets or sets optional action invoked after NavigationView is loaded.
@@ -54,6 +56,23 @@ public sealed partial class SettingsWindow : WindowEx,
_localKeyboardListener.Start(); _localKeyboardListener.Start();
Closed += SettingsWindow_Closed; Closed += SettingsWindow_Closed;
RootElement.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(RootElement_OnPointerPressed), true); RootElement.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(RootElement_OnPointerPressed), true);
if (!BuildInfo.IsCiBuild)
{
_internalNavItem = new NavigationViewItem
{
Content = "Internal Tools",
Icon = new FontIcon { Glyph = "\uEC7A" },
Tag = "Internal",
};
NavView.MenuItems.Add(_internalNavItem);
}
else
{
_internalNavItem = null;
}
Navigate("General");
} }
private void SettingsWindow_Closed(object sender, WindowEventArgs args) private void SettingsWindow_Closed(object sender, WindowEventArgs args)
@@ -68,9 +87,6 @@ public sealed partial class SettingsWindow : WindowEx,
// Delay necessary to ensure NavigationView visual state can match navigation // Delay necessary to ensure NavigationView visual state can match navigation
Task.Delay(500).ContinueWith(_ => this.NavigationViewLoaded?.Invoke(), TaskScheduler.FromCurrentSynchronizationContext()); Task.Delay(500).ContinueWith(_ => this.NavigationViewLoaded?.Invoke(), TaskScheduler.FromCurrentSynchronizationContext());
NavView.SelectedItem = NavView.MenuItems[0];
Navigate("General");
if (sender is NavigationView navigationView) if (sender is NavigationView navigationView)
{ {
// Register for pane open/close changes to announce to screen readers // Register for pane open/close changes to announce to screen readers
@@ -96,15 +112,33 @@ public sealed partial class SettingsWindow : WindowEx,
Navigate((selectedItem.Tag as string)!); Navigate((selectedItem.Tag as string)!);
} }
private void Navigate(string page) internal void Navigate(string page)
{ {
var pageType = page switch Type? pageType;
switch (page)
{ {
"General" => typeof(GeneralPage), case "General":
"Appearance" => typeof(AppearancePage), pageType = typeof(GeneralPage);
"Extensions" => typeof(ExtensionsPage), break;
_ => null, case "Appearance":
}; pageType = typeof(AppearancePage);
break;
case "Extensions":
pageType = typeof(ExtensionsPage);
break;
case "Internal":
pageType = typeof(InternalPage);
break;
case "":
// intentional no-op: empty tag means no navigation
pageType = null;
break;
default:
// unknown page, no-op and log
pageType = null;
Logger.LogError($"Unknown settings page tag '{page}'");
break;
}
if (pageType is not null) if (pageType is not null)
{ {
@@ -268,6 +302,12 @@ public sealed partial class SettingsWindow : WindowEx,
BreadCrumbs.Add(new(extensionsPageType, extensionsPageType)); BreadCrumbs.Add(new(extensionsPageType, extensionsPageType));
BreadCrumbs.Add(new(vm.DisplayName, vm)); BreadCrumbs.Add(new(vm.DisplayName, vm));
} }
else if (e.SourcePageType == typeof(InternalPage) && _internalNavItem is not null)
{
NavView.SelectedItem = _internalNavItem;
var pageType = "Internal";
BreadCrumbs.Add(new(pageType, pageType));
}
else else
{ {
BreadCrumbs.Add(new($"[{e.SourcePageType?.Name}]", string.Empty)); BreadCrumbs.Add(new($"[{e.SourcePageType?.Name}]", string.Empty));

View File

@@ -8,8 +8,10 @@ using System.Globalization;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon; using ManagedCommon;
using Microsoft.CmdPal.UI.Helpers; using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.UI; using Microsoft.UI;
using Windows.System; using Windows.System;
using Windows.UI; using Windows.UI;
@@ -99,6 +101,12 @@ internal sealed partial class DevRibbonViewModel : ObservableObject
LatestLogs.Clear(); LatestLogs.Clear();
} }
[RelayCommand]
private void OpenInternalTools()
{
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage("Internal"));
}
private sealed partial class DevRibbonTraceListener(DevRibbonViewModel viewModel) : TraceListener private sealed partial class DevRibbonTraceListener(DevRibbonViewModel viewModel) : TraceListener
{ {
private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff"; private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff";

View File

@@ -0,0 +1,9 @@
// 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.
global using System;
global using System.Collections.Generic;
global using System.Diagnostics.CodeAnalysis;
global using System.Linq;
global using Microsoft.VisualStudio.TestTools.UnitTesting;

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>Microsoft.CmdPal.Common.UnitTests</RootNamespace>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Moq" />
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\Microsoft.CmdPal.Core.Common\Microsoft.CmdPal.Core.Common.csproj" />
<ProjectReference Include="..\Microsoft.CmdPal.Ext.UnitTestsBase\Microsoft.CmdPal.Ext.UnitTestBase.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,107 @@
// 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.Common.UnitTests.TestUtils;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
[TestClass]
public class ConnectionStringRuleProviderTests
{
[TestMethod]
public void GetRules_ShouldReturnExpectedRules()
{
// Arrange
var provider = new ConnectionStringRuleProvider();
// Act
var rules = provider.GetRules();
// Assert
var ruleList = new List<SanitizationRule>(rules);
Assert.AreEqual(1, ruleList.Count);
Assert.AreEqual("Connection string parameters", ruleList[0].Description);
}
[DataTestMethod]
[DataRow("Server=localhost;Database=mydb;User ID=admin;Password=secret123", "Server=[REDACTED];Database=[REDACTED];User ID=[REDACTED];Password=[REDACTED]")]
[DataRow("Data Source=server.example.com;Initial Catalog=testdb;Uid=user;Pwd=pass", "Data Source=[REDACTED];Initial Catalog=[REDACTED];Uid=[REDACTED];Pwd=[REDACTED]")]
[DataRow("Server=localhost;Password=my_secret", "Server=[REDACTED];Password=[REDACTED]")]
[DataRow("No connection string here", "No connection string here")]
public void ConnectionStringRules_ShouldMaskConnectionStringParameters(string input, string expected)
{
// Arrange
var provider = new ConnectionStringRuleProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("Password=\"complexPassword123!\"", "Password=[REDACTED]")]
[DataRow("Password='myPassword'", "Password=[REDACTED]")]
[DataRow("Password=unquotedSecret", "Password=[REDACTED]")]
public void ConnectionStringRules_ShouldHandleQuotedAndUnquotedValues(string input, string expected)
{
// Arrange
var provider = new ConnectionStringRuleProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("SERVER=server1;PASSWORD=pass1", "SERVER=[REDACTED];PASSWORD=[REDACTED]")]
[DataRow("server=server1;password=pass1", "server=[REDACTED];password=[REDACTED]")]
[DataRow("Server=server1;Password=pass1", "Server=[REDACTED];Password=[REDACTED]")]
public void ConnectionStringRules_ShouldBeCaseInsensitive(string input, string expected)
{
// Arrange
var provider = new ConnectionStringRuleProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("User ID=admin;Username=john;Password=secret", "User ID=[REDACTED];Username=[REDACTED];Password=[REDACTED]")]
[DataRow("Database=mydb;Uid=user1;Pwd=pass1;Server=localhost", "Database=[REDACTED];Uid=[REDACTED];Pwd=[REDACTED];Server=[REDACTED]")]
public void ConnectionStringRules_ShouldHandleMultipleParameters(string input, string expected)
{
// Arrange
var provider = new ConnectionStringRuleProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("Server = localhost ; Password = secret123", "Server=[REDACTED] ; Password=[REDACTED]")]
[DataRow("Initial Catalog=db; User ID=admin; Password=pass", "Initial Catalog=[REDACTED]; User ID=[REDACTED]; Password=[REDACTED]")]
public void ConnectionStringRules_ShouldHandleWhitespace(string input, string expected)
{
// Arrange
var provider = new ConnectionStringRuleProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
}

View File

@@ -0,0 +1,81 @@
// 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.Common.UnitTests.Services.Sanitizer;
public partial class ErrorReportSanitizerTests
{
private static class TestData
{
internal static string Input =>
$"""
HRESULT: 0x80004005
HRESULT: -2147467259
Here is e-mail address <jane.doe@contoso.com>
IPv4 address: 192.168.100.1
IPv4 loopback address: 127.0.0.1
MAC address: 00-14-22-01-23-45
IPv6 address: 2001:0db8:85a3:0000:0000:8a2e:0370:7334
IPv6 loopback address: ::1
Password: P@ssw0rd123!
Password=secret
Api key: 1234567890abcdef
PostgreSQL connection string: Host=localhost;Username=postgres;Password=secret;Database=mydb
InstrumentationKey=00000000-0000-0000-0000-000000000000;EndpointSuffix=ai.contoso.com;
X-API-key: 1234567890abcdef
Pet-Shop-Subscription-Key: 1234567890abcdef
Here is a user name {Environment.UserName}
And here is a profile path {Environment.GetFolderPath(Environment.SpecialFolder.UserProfile)}\RandomFolder
Here is a local app data path {Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\PowerToys\CmdPal
Here is machine name {Environment.MachineName}
JWT token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30
User email john.doe@company.com failed validation
File not found: {Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)}\\secret.txt
Connection string: Server=localhost;User ID=admin;Password=secret123;Database=test
Phone number 555-123-4567 is invalid
API key abc123def456ghi789jkl012mno345pqr678 expired
Failed to connect to https://api.internal-company.com/users/12345?token=secret_abc123
Error accessing file://C:/Users/john.doe/Documents/confidential.pdf
JDBC connection failed: jdbc://database-server:5432/userdb?user=admin&password=secret
FTP upload error: ftp://internal-server.company.com/uploads/user_data.csv
Email service error: mailto:admin@internal-company.com?subject=Alert
""";
public const string Expected =
$"""
HRESULT: 0x80004005
HRESULT: -2147467259
Here is e-mail address <[EMAIL_REDACTED]>
IPv4 address: [IP4_REDACTED]
IPv4 loopback address: [IP4_REDACTED]
MAC address: [MAC_ADDRESS_REDACTED]
IPv6 address: [IP6_REDACTED]
IPv6 loopback address: [IP6_REDACTED]
Password: [REDACTED]
Password= [REDACTED]
Api key: [REDACTED]
PostgreSQL connection string: [REDACTED]
InstrumentationKey= [REDACTED]
X-API-key: [REDACTED]
Pet-Shop-Subscription-Key: [REDACTED]
Here is a user name [USERNAME_REDACTED]
And here is a profile path [USER_PROFILE_DIR]RandomFolder
Here is a local app data path [LOCALAPPLICATIONDATA_DIR]Microsoft\PowerToys\CmdPal
Here is machine name [MACHINE_NAME_REDACTED]
JWT token: [REDACTED]
User email [EMAIL_REDACTED] failed validation
File not found: [MYDOCUMENTS_DIR]se****.txt
Connection string: [REDACTED] ID=[REDACTED];Password= [REDACTED]
Phone number [PHONE_REDACTED] is invalid
API key [TOKEN_REDACTED] expired
Failed to connect to [URL_REDACTED]
Error accessing [URL_REDACTED]
JDBC connection failed: [URL_REDACTED]
FTP upload error: [URL_REDACTED]
Email service error: mailto:[EMAIL_REDACTED]?subject=Alert
""";
}
}

View File

@@ -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 Microsoft.CmdPal.Core.Common.Services.Sanitizer;
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
[TestClass]
public partial class ErrorReportSanitizerTests
{
[TestMethod]
public void Sanitize_ShouldMaskPiiInErrorReport()
{
// Arrange
var reportSanitizer = new ErrorReportSanitizer();
var input = TestData.Input;
// Act
var result = reportSanitizer.Sanitize(input);
// Assert
Assert.AreEqual(TestData.Expected, result);
}
}

View File

@@ -0,0 +1,122 @@
// 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.Common.UnitTests.TestUtils;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
[TestClass]
public class PiiRuleProviderTests
{
[TestMethod]
public void GetRules_ShouldReturnExpectedRules()
{
// Arrange
var provider = new PiiRuleProvider();
// Act
var rules = provider.GetRules();
// Assert
var ruleList = new List<SanitizationRule>(rules);
Assert.AreEqual(4, ruleList.Count);
Assert.AreEqual("Email addresses", ruleList[0].Description);
Assert.AreEqual("Social Security Numbers", ruleList[1].Description);
Assert.AreEqual("Credit card numbers", ruleList[2].Description);
Assert.AreEqual("Phone numbers", ruleList[3].Description);
}
[DataTestMethod]
[DataRow("Contact me at john.doe@contoso.com", "Contact me at [EMAIL_REDACTED]")]
[DataRow("Contact me at a_b-c%2@foo-bar.example.co.uk", "Contact me at [EMAIL_REDACTED]")]
[DataRow("My email is john@sub-domain.contoso.com.", "My email is [EMAIL_REDACTED].")]
[DataRow("Two: a@b.com and c@d.org", "Two: [EMAIL_REDACTED] and [EMAIL_REDACTED]")]
[DataRow("No email here", "No email here")]
public void EmailRules_ShouldMaskEmailAddresses(string input, string expected)
{
// Arrange
var provider = new PiiRuleProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("Call me at 123-456-7890", "Call me at [PHONE_REDACTED]")]
[DataRow("My number is (123) 456-7890.", "My number is [PHONE_REDACTED].")]
[DataRow("Office: +1 123 456 7890", "Office: [PHONE_REDACTED]")]
[DataRow("Two numbers: 123-456-7890 and +420 777123456", "Two numbers: [PHONE_REDACTED] and [PHONE_REDACTED]")]
[DataRow("Czech phone +420 777 123 456", "Czech phone [PHONE_REDACTED]")]
[DataRow("Slovak phone +421 777 12 34 56", "Slovak phone [PHONE_REDACTED]")]
[DataRow("No phone number here", "No phone number here")]
public void PhoneRules_ShouldMaskPhoneNumbers(string input, string expected)
{
// Arrange
var provider = new PiiRuleProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("My SSN is 123-45-6789", "My SSN is [SSN_REDACTED]")]
[DataRow("No SSN here", "No SSN here")]
public void SsnRules_ShouldMaskSsn(string input, string expected)
{
// Arrange
var provider = new PiiRuleProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("My credit card number is 1234-5678-9012-3456", "My credit card number is [CARD_REDACTED]")]
[DataRow("My credit card number is 1234567890123456", "My credit card number is [CARD_REDACTED]")]
[DataRow("No credit card here", "No credit card here")]
public void CreditCardRules_ShouldMaskCreditCardNumbers(string input, string expected)
{
// Arrange
var provider = new PiiRuleProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("Error code: 0x80070005", "Error code: 0x80070005")]
[DataRow("Error code: -2147467262", "Error code: -2147467262")]
[DataRow("GUID: 123e4567-e89b-12d3-a456-426614174000", "GUID: 123e4567-e89b-12d3-a456-426614174000")]
[DataRow("Timestamp: 2023-10-05T14:32:10Z", "Timestamp: 2023-10-05T14:32:10Z")]
[DataRow("Version: 1.2.3", "Version: 1.2.3")]
[DataRow("Version: 10.0.22631.3448", "Version: 10.0.22631.3448")]
[DataRow("MAC: 00:1A:2B:3C:4D:5E", "MAC: 00:1A:2B:3C:4D:5E")]
[DataRow("Date: 2023-10-05", "Date: 2023-10-05")]
[DataRow("Date: 05/10/2023", "Date: 05/10/2023")]
public void PiiRuleProvider_ShouldNotOverRedact(string input, string expected)
{
// Arrange
var provider = new PiiRuleProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
}

View File

@@ -0,0 +1,266 @@
// 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.Common.UnitTests.TestUtils;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer.Abstraction;
namespace Microsoft.CmdPal.Common.UnitTests.Services.Sanitizer;
[TestClass]
public class SecretKeyValueRulesProviderTests
{
[TestMethod]
public void GetRules_ShouldReturnExpectedRules()
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var rules = provider.GetRules();
// Assert
var ruleList = new List<SanitizationRule>(rules);
Assert.AreEqual(1, ruleList.Count);
Assert.AreEqual("Sensitive key/value pairs", ruleList[0].Description);
}
[DataTestMethod]
[DataRow("password=secret123", "password= [REDACTED]")]
[DataRow("passphrase=myPassphrase", "passphrase= [REDACTED]")]
[DataRow("pwd=test", "pwd= [REDACTED]")]
[DataRow("passwd=pass1234", "passwd= [REDACTED]")]
public void SecretKeyValueRules_ShouldMaskPasswordSecrets(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("token=abc123def456", "token= [REDACTED]")]
[DataRow("access_token=token_value", "access_token= [REDACTED]")]
[DataRow("refresh-token=refresh_value", "refresh-token= [REDACTED]")]
[DataRow("id token=id_token_value", "id token= [REDACTED]")]
[DataRow("bearer token=bearer_value", "bearer token= [REDACTED]")]
[DataRow("session token=session_value", "session token= [REDACTED]")]
public void SecretKeyValueRules_ShouldMaskTokens(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("api key=my_api_key", "api key= [REDACTED]")]
[DataRow("api-key=key123", "api-key= [REDACTED]")]
[DataRow("api_key=secret_key", "api_key= [REDACTED]")]
[DataRow("x-api-key=api123", "x-api-key= [REDACTED]")]
[DataRow("x api key=key456", "x api key= [REDACTED]")]
[DataRow("client id=client123", "client id= [REDACTED]")]
[DataRow("client-secret=secret123", "client-secret= [REDACTED]")]
[DataRow("consumer secret=secret456", "consumer secret= [REDACTED]")]
public void SecretKeyValueRules_ShouldMaskApiCredentials(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("subscription key=sub_key_123", "subscription key= [REDACTED]")]
[DataRow("instrumentation key=instr_key", "instrumentation key= [REDACTED]")]
[DataRow("account key=account123", "account key= [REDACTED]")]
[DataRow("storage account key=storage_key", "storage account key= [REDACTED]")]
[DataRow("shared access key=sak123", "shared access key= [REDACTED]")]
[DataRow("SAS token=sas123", "SAS token= [REDACTED]")]
public void SecretKeyValueRules_ShouldMaskCloudPlatformKeys(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("connection string=Server=localhost;Pwd=pass", "connection string= [REDACTED]")]
[DataRow("conn string=conn_value", "conn string= [REDACTED]")]
[DataRow("storage connection string=connection_value", "storage connection string= [REDACTED]")]
public void SecretKeyValueRules_ShouldMaskConnectionStrings(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("private key=pk123", "private key= [REDACTED]")]
[DataRow("certificate password=cert_pass", "certificate password= [REDACTED]")]
[DataRow("client certificate password=cert123", "client certificate password= [REDACTED]")]
[DataRow("pfx password=pfx_pass", "pfx password= [REDACTED]")]
public void SecretKeyValueRules_ShouldMaskCertificateSecrets(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("aws access key id=AKIAIOSFODNN7EXAMPLE", "aws access key id= [REDACTED]")]
[DataRow("aws secret access key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", "aws secret access key= [REDACTED]")]
[DataRow("aws session token=session_token_value", "aws session token= [REDACTED]")]
public void SecretKeyValueRules_ShouldMaskAwsKeys(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("password=\"complexPassword123!\"", "password= \"[REDACTED]\"")]
[DataRow("api-key='secret-key'", "api-key= '[REDACTED]'")]
[DataRow("token=\"bearer_token_value\"", "token= \"[REDACTED]\"")]
public void SecretKeyValueRules_ShouldPreserveQuotesAroundRedactedValue(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("PASSWORD=secret", "PASSWORD= [REDACTED]")]
[DataRow("Api-Key=key123", "Api-Key= [REDACTED]")]
[DataRow("CLIENT_ID=client123", "CLIENT_ID= [REDACTED]")]
[DataRow("Pwd=pass123", "Pwd= [REDACTED]")]
public void SecretKeyValueRules_ShouldBeCaseInsensitive(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("regularKey=regularValue", "regularKey=regularValue")]
[DataRow("config=myConfig", "config=myConfig")]
[DataRow("hostname=server.example.com", "hostname=server.example.com")]
[DataRow("port=8080", "port=8080")]
public void SecretKeyValueRules_ShouldNotRedactNonSecretKeyValuePairs(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("password:secret123", "password: [REDACTED]")]
[DataRow("api key:api_key_value", "api key: [REDACTED]")]
[DataRow("client_secret:secret_value", "client_secret: [REDACTED]")]
public void SecretKeyValueRules_ShouldSupportColonSeparator(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("password = secret123", "password= [REDACTED]")]
[DataRow("api key = api_key_value", "api key= [REDACTED]")]
[DataRow("token : token_value", "token: [REDACTED]")]
public void SecretKeyValueRules_ShouldHandleWhitespace(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("password=secret API_KEY=key config=myConfig", "password= [REDACTED] API_KEY= [REDACTED] config=myConfig")]
[DataRow("client_id=id123 name=admin pwd=pass123", "client_id= [REDACTED] name=admin pwd= [REDACTED]")]
public void SecretKeyValueRules_ShouldHandleMultipleKeyValuePairsInSingleString(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
[DataTestMethod]
[DataRow("cosmos db key=cosmos_key", "cosmos db key= [REDACTED]")]
[DataRow("service principal secret=sp_secret", "service principal secret= [REDACTED]")]
[DataRow("shared access signature=sas_signature", "shared access signature= [REDACTED]")]
public void SecretKeyValueRules_ShouldMaskServiceSpecificSecrets(string input, string expected)
{
// Arrange
var provider = new SecretKeyValueRulesProvider();
// Act
var result = SanitizerTestHelper.ApplyRules(input, provider.GetRules());
// Assert
Assert.AreEqual(expected, result);
}
}

View File

@@ -0,0 +1,110 @@
// 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.Common.UnitTests.TestUtils;
/// <summary>
/// Test-only helpers for applying SanitizationRule sets without relying on production ITextSanitizer implementation.
/// </summary>
public static class SanitizerTestHelper
{
/// <summary>
/// Applies the provided rules to the input, in order, mimicking the production sanitizer behavior closely
/// but without any external dependencies.
/// </summary>
public static string ApplyRules(string? input, IEnumerable<SanitizationRule> rules)
{
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 ?? string.Empty)
: rule.Regex.Replace(previous, rule.Evaluator);
// Guardrail to avoid accidental mass-redaction from a faulty rule
if (result.Length < previous.Length * 0.3)
{
result = previous;
}
}
catch (RegexMatchTimeoutException)
{
// Ignore timeouts in tests
}
}
return result;
}
/// <summary>
/// Creates a lightweight sanitizer instance backed by the given rules.
/// Useful when a component expects an ITextSanitizer, but you want deterministic behavior in tests.
/// </summary>
public static ITextSanitizer CreateSanitizer(IEnumerable<SanitizationRule> rules)
=> new InlineSanitizer(rules);
private sealed class InlineSanitizer : ITextSanitizer
{
private readonly List<SanitizationRule> _rules;
public InlineSanitizer(IEnumerable<SanitizationRule> rules)
{
_rules = rules?.ToList() ?? [];
}
public string Sanitize(string? input) => ApplyRules(input, _rules);
public void AddRule(string pattern, string replacement, string description = "")
{
var rx = new Regex(pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant);
_rules.Add(new SanitizationRule(rx, replacement, description));
}
public void RemoveRule(string description)
{
_rules.RemoveAll(r => r.Description.Equals(description, StringComparison.OrdinalIgnoreCase));
}
public IReadOnlyList<SanitizationRule> 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 for test determinism
}
return input;
}
}
}