mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-04-03 09:46:54 +02:00
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:
@@ -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} =
|
||||||
|
|||||||
2
.github/actions/spell-check/excludes.txt
vendored
2
.github/actions/spell-check/excludes.txt
vendored
@@ -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$
|
||||||
|
|||||||
4
.github/actions/spell-check/expect.txt
vendored
4
.github/actions/spell-check/expect.txt
vendored
@@ -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
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
76
src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs
generated
Normal file
76
src/modules/cmdpal/Core/Microsoft.CmdPal.Core.Common/Properties/Resources.Designer.cs
generated
Normal 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 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>
|
||||||
|
internal static string ErrorReport_Global_Preamble {
|
||||||
|
get {
|
||||||
|
return ResourceManager.GetString("ErrorReport_Global_Preamble", resourceCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 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.)</value>
|
||||||
|
</data>
|
||||||
|
</root>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>"}";
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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}) # 7–15 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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -43,4 +43,10 @@
|
|||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||||
|
<_Parameter1>$(AssemblyName).UnitTests</_Parameter1>
|
||||||
|
</AssemblyAttribute>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,4 @@
|
|||||||
|
|
||||||
namespace Microsoft.CmdPal.UI.Messages;
|
namespace Microsoft.CmdPal.UI.Messages;
|
||||||
|
|
||||||
public record OpenSettingsMessage()
|
public record OpenSettingsMessage(string SettingsPageTag = "");
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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=}"
|
||||||
|
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=}">
|
||||||
|
<Button Click="OpenLogsCardClicked" Content="Open folder" />
|
||||||
|
</controls:SettingsCard>
|
||||||
|
<controls:SettingsCard
|
||||||
|
x:Name="CurrentLogFileSettingsCard"
|
||||||
|
Header="Current log file"
|
||||||
|
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||||
|
<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=}">
|
||||||
|
<Button Click="OpenConfigFolderCardClick" Content="Open folder" />
|
||||||
|
</controls:SettingsCard>
|
||||||
|
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,7 @@
|
|||||||
x:Uid="Settings_GeneralPage_NavigationViewItem_Extensions"
|
x:Uid="Settings_GeneralPage_NavigationViewItem_Extensions"
|
||||||
Icon="{ui:FontIcon Glyph=}"
|
Icon="{ui:FontIcon Glyph=}"
|
||||||
Tag="Extensions" />
|
Tag="Extensions" />
|
||||||
|
<!-- "Internal Tools" page item is added dynamically from code -->
|
||||||
</NavigationView.MenuItems>
|
</NavigationView.MenuItems>
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user