MSIX.Scanners.ps1
|
# ============================================================================= # MSIX scanners (split from MSIX.Heuristics.ps1 in issue #38) # ----------------------------------------------------------------------------- # Read-only inspectors that produce findings -- Get-Msix*Candidate / *Entry / # *Hint / HeuristicFinding. None of these mutate the package. # Mutator counterparts live in MSIX.PackageMutators.ps1; the auto-fix # orchestrators in MSIX.AutoFix.ps1. # ============================================================================= function _MsixEscapeSingleQuote { <# SECURITY: many findings embed package-derived values (handler names, DLL / VFS paths, AppIds, ...) into single-quoted PowerShell command fragments that an operator may copy-paste and run. A value containing a single quote would otherwise close the literal and inject commands into the suggested line. Doubling embedded single quotes keeps the value an inert literal. Returns a string safe to place between single quotes. #> param([string]$Value) return ([string]$Value).Replace("'", "''") } function _MsixResolveScanWorkspace { <# PERFORMANCE (#58): the read-only scanners used to each unpack the whole package independently, so a single Get-MsixHeuristicFinding / static-analysis run unpacked the package ~14 times. This helper lets a scanner accept a pre-unpacked workspace from its caller and skip its own unpack. Returns a descriptor: @{ Path = <workspace dir>; Owned = $true|$false } - When -WorkspacePath is supplied: returns it with Owned=$false (the caller unpacked it and is responsible for cleanup). - Otherwise: unpacks $PackagePath into a fresh workspace and returns it with Owned=$true (the scanner must Remove-Item it in its finally). The scanner pattern becomes: $ws = _MsixResolveScanWorkspace -PackagePath $PackagePath -WorkspacePath $WorkspacePath -Label 'unin' try { ... scan $ws.Path ... } finally { if ($ws.Owned) { Remove-Item ... } } #> [OutputType([hashtable])] param( [Parameter(Mandatory)][string]$PackagePath, [AllowNull()][AllowEmptyString()][string]$WorkspacePath, [Parameter(Mandatory)][string]$Label ) if ($WorkspacePath) { return @{ Path = $WorkspacePath; Owned = $false } } $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item -LiteralPath $PackagePath $workspace = New-MsixWorkspace -PackageName "$($fileinfo.BaseName)-$Label" $r = Invoke-MsixProcess -FilePath "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o') Assert-MsixProcessSuccess -Result $r -Operation 'MakeAppx unpack' return @{ Path = $workspace; Owned = $true } } # --------------------------------------------------------------------------- # Uninstaller / updater / desktop-shortcut scanners # --------------------------------------------------------------------------- function Get-MsixUninstallerCandidate { <# .SYNOPSIS Lists files inside the package that look like leftover installer or uninstaller artefacts. These commonly break MSIX install/uninstall flows and should usually be removed before publishing. .DESCRIPTION Pattern matches against well-known uninstaller filenames (uninst*.exe, unins*.exe, setup.exe, install.exe, autorun.inf, InstallShield/MSI scratch files). Detection-only — pair with Remove-MsixUninstallerArtifact to strip the matched files and the matching Uninstall\* registry leftovers. Feeds the `UninstallerArtifact` finding in Get-MsixHeuristicFinding. .PARAMETER PackagePath .msix to scan (read-only). .EXAMPLE # List uninstaller leftovers, then remove them in a follow-up call Get-MsixUninstallerCandidate -PackagePath app.msix Remove-MsixUninstallerArtifact -PackagePath app.msix -SkipSigning .OUTPUTS [pscustomobject] one per match: Name, Path (package-relative), SizeBytes. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$PackagePath, [string]$WorkspacePath ) $patterns = @( '^uninst.*\.exe$', '^unins.*\.exe$', '^setup\.exe$', '^install\.exe$', '^_isres.*$', '^autorun\.inf$', '^Setup\.msi$', '^uninstall\.exe$', '^uninstaller.*\.exe$' ) $ws = _MsixResolveScanWorkspace -PackagePath $PackagePath -WorkspacePath $WorkspacePath -Label 'unin' $workspace = $ws.Path try { Get-ChildItem -LiteralPath $workspace -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $name = $_.Name ($patterns | Where-Object { $name -match $_ }).Count -gt 0 } | ForEach-Object { [pscustomobject]@{ Name = $_.Name Path = $_.FullName.Substring($workspace.Length + 1) SizeBytes = $_.Length } } } finally { if ($ws.Owned) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } } } function Get-MsixUpdaterCandidate { <# .SYNOPSIS Lists files inside the package that look like auto-updater binaries or scheduled-task artefacts. Auto-updaters typically fail (or worse, damage the install) inside the MSIX container and should be removed before publishing. .DESCRIPTION Pattern matches against well-known auto-updater filename shapes (Updater.exe, *UpdateSvc*.exe, *Sparkle*.dll, *Squirrel*.exe, GoogleUpdate*.exe, MicrosoftEdgeUpdate*.exe, omaha*.exe, *AutoUpdater*.exe, *MaintenanceService*.exe) and flags any *.xml shipped under a Tasks\ or VFS\Windows\Tasks\ folder as scheduled-task artefacts. Detection-only — pair with Remove-MsixUpdaterArtifact to strip the matched files. Feeds the `UpdaterArtifact` finding in Get-MsixHeuristicFinding. False-positive guard: filenames also matching PSF helpers, MSVC / UCRT redistributables, or the MSIX runtime itself are skipped. .PARAMETER PackagePath .msix to scan (read-only). .EXAMPLE # List updater leftovers, then remove them in a follow-up call Get-MsixUpdaterCandidate -PackagePath app.msix Remove-MsixUpdaterArtifact -PackagePath app.msix -SkipSigning .OUTPUTS [pscustomobject] one per match: RelativePath, LeafName, Kind ('Binary' or 'ScheduledTask'), Reason. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$PackagePath, [string]$WorkspacePath ) $patterns = @( '^.*updater?\.exe$', '^.*updatesvc.*\.exe$', '^.*sparkle.*\.(dll|exe)$', '^.*squirrel.*\.exe$', '^GoogleUpdate.*\.exe$', '^MicrosoftEdgeUpdate.*\.exe$', '^omaha.*\.exe$', '^.*autoupdater?.*\.exe$', '^.*maintenanceservice.*\.exe$', '^.*winsparkle.*\.(dll|exe)$' ) $excludePatterns = @('^psf', '^msvc', '^vcruntime', '^api-ms-win-', '^msix') $ws = _MsixResolveScanWorkspace -PackagePath $PackagePath -WorkspacePath $WorkspacePath -Label 'upd' $workspace = $ws.Path try { Get-ChildItem -LiteralPath $workspace -Recurse -File -ErrorAction SilentlyContinue | ForEach-Object { $leaf = $_.Name $rel = $_.FullName.Substring($workspace.Length + 1) # False-positive guard $skip = $false foreach ($ex in $excludePatterns) { if ($leaf -match $ex) { $skip = $true; break } } if ($skip) { return } # Binary signal $matched = $null foreach ($p in $patterns) { if ($leaf -match $p) { $matched = $p; break } } if ($matched) { [pscustomobject]@{ RelativePath = $rel LeafName = $leaf Kind = 'Binary' Reason = "Matches updater binary pattern: $matched" } return } # Scheduled-task XML signal if ($leaf -match '\.xml$') { $relLower = $rel.ToLowerInvariant() if ($relLower -match '(^|\\)tasks\\' -or $relLower -match '\\vfs\\windows\\tasks\\') { [pscustomobject]@{ RelativePath = $rel LeafName = $leaf Kind = 'ScheduledTask' Reason = 'Scheduled task XML under Tasks/' } } } } } finally { if ($ws.Owned) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } } } function Get-MsixUninstallRegistryEntry { <# .SYNOPSIS Reads the package's virtualized HKLM hive (Registry.dat) and returns the Uninstall\* subkeys baked in by the original installer. These are leftover and don't function inside an MSIX container. .DESCRIPTION Parses Registry.dat in-memory via offreg.dll (Offline Registry API). Walks SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall and the WOW6432Node equivalent, captures DisplayName / DisplayVersion / Publisher / UninstallString for each entry. Works WITHOUT elevation. The original implementation used reg.exe load which requires SeBackupPrivilege + SeRestorePrivilege regardless of mount point (HKLM, HKU, HKCU). offreg.dll parses the hive from disk without mounting it into the live registry, so no privileges are needed. .PARAMETER PackagePath .msix file (read-only). .EXAMPLE # Surface leftover uninstall registry entries (no elevation required) Get-MsixUninstallRegistryEntry -PackagePath app.msix | Select-Object DisplayName, Publisher, UninstallString .OUTPUTS [pscustomobject[]] each with KeyName, DisplayName, DisplayVersion, Publisher, UninstallString, FullPath. Returns an empty array when Registry.dat has no Uninstall\* subkeys. #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)][string]$PackagePath, [string]$WorkspacePath ) $ws = _MsixResolveScanWorkspace -PackagePath $PackagePath -WorkspacePath $WorkspacePath -Label 'uninreg' $workspace = $ws.Path try { $datPath = Join-Path -Path $workspace -ChildPath 'Registry.dat' if (-not (Test-Path -LiteralPath $datPath)) { Write-MsixLog -Level Info -Message 'No Registry.dat in package.' return @() } $hive = _MsixOpenOfflineHive -Path $datPath try { $entries = [System.Collections.Generic.List[object]]::new() foreach ($branch in @( 'REGISTRY\MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', 'REGISTRY\MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall' )) { $branchKey = _MsixOfflineOpenKey -Parent $hive -SubKey $branch if ($branchKey -eq [IntPtr]::Zero) { continue } try { foreach ($child in (_MsixOfflineEnumSubKeys -Key $branchKey)) { $entries.Add([pscustomobject]@{ KeyName = $child DisplayName = _MsixOfflineGetValue -Parent $hive -SubKey "$branch\$child" -Name 'DisplayName' DisplayVersion = _MsixOfflineGetValue -Parent $hive -SubKey "$branch\$child" -Name 'DisplayVersion' Publisher = _MsixOfflineGetValue -Parent $hive -SubKey "$branch\$child" -Name 'Publisher' UninstallString = _MsixOfflineGetValue -Parent $hive -SubKey "$branch\$child" -Name 'UninstallString' FullPath = "HKLM:\$($branch -replace 'REGISTRY\\MACHINE\\', '')\$child" }) } } finally { _MsixOfflineCloseKey -Key $branchKey } } return $entries.ToArray() } finally { _MsixCloseOfflineHive -Hive $hive } } finally { if ($ws.Owned) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } } } #region Run-keys (HKLM\Run autostart) --------------------------------------- function Get-MsixRunKeyEntry { <# .SYNOPSIS Lists the HKLM/HKCU \…\Run\* entries declared by the package — usually baked in by the original installer. These don't fire under MSIX and admins typically remove them or replace with a startScript. .DESCRIPTION Inspects Registry.dat and User.dat hives shipped in the package by parsing them with offreg.dll (no elevation, no live mount) and enumerating the values under each hive's Software\Microsoft\Windows\CurrentVersion\Run (and the WOW6432Node variant) key. Feeds the `RunKey` finding in Get-MsixHeuristicFinding, which in turn drives the `ManifestFix:StartupTask` recommendation when the package has no windows.startupTask extension declared. This replaces the previous raw-string Unicode scan of the whole hive, which was vulnerable to ReDoS / memory blow-up on a hostile hive and produced both false positives (matches in unrelated binary noise) and false negatives (strings not 2-byte aligned). .PARAMETER PackagePath .msix to scan (read-only). .EXAMPLE # Find Run-key autostart leftovers Get-MsixRunKeyEntry -PackagePath app.msix .OUTPUTS [pscustomobject[]] each with Hive ('Registry.dat' or 'User.dat'), Match (the logical Run-key path including the value name), Name (the value name = autostart entry), and Command (the value data). #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$PackagePath, [string]$WorkspacePath ) $ws = _MsixResolveScanWorkspace -PackagePath $PackagePath -WorkspacePath $WorkspacePath -Label 'runkeys' $workspace = $ws.Path try { # MSIX packages ship Registry.dat (HKLM) + User.dat (HKCU) for the # virtual hive. The branch prefix differs: Registry.dat carries the # REGISTRY\MACHINE root, User.dat is rooted at the user hive directly. $hiveBranches = @{ 'Registry.dat' = @( 'REGISTRY\MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' 'REGISTRY\MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run' ) 'User.dat' = @( 'Software\Microsoft\Windows\CurrentVersion\Run' 'Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Run' ) } $hits = [System.Collections.Generic.List[object]]::new() foreach ($dat in @('Registry.dat','User.dat')) { $datPath = Join-Path -Path $workspace -ChildPath $dat if (-not (Test-Path -LiteralPath $datPath)) { continue } try { _MsixWithOfflineHive -Path $datPath -ScriptBlock { param($hive) foreach ($branch in $hiveBranches[$dat]) { $runKey = _MsixOfflineOpenKey -Parent $hive -SubKey $branch if ($runKey -eq [IntPtr]::Zero) { continue } try { foreach ($name in (_MsixOfflineEnumValueNames -Key $runKey)) { if ([string]::IsNullOrEmpty($name)) { continue } # skip default value $command = _MsixOfflineGetValue -Parent $hive -SubKey $branch -Name $name $hits.Add([pscustomobject]@{ Hive = $dat Match = "$branch\$name" Name = $name Command = $command }) } } finally { _MsixOfflineCloseKey -Key $runKey } } } } catch { Write-MsixLog -Level Debug -Message "Run-key scan failed for $dat`: $_" } } return $hits.ToArray() | Sort-Object Hive,Match -Unique } finally { if ($ws.Owned) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } } } #endregion #region Shell context-menu entries ------------------------------------------- function _MsixAbsoluteToVfsRelativeDirect { <# .SYNOPSIS Translates an absolute DLL path (from Registry.dat) to a VFS-relative path within an already-unpacked MSIX workspace. Returns $null if the file cannot be mapped / does not exist in the package. #> param([string]$AbsPath, [string]$WorkspacePath) if (-not $AbsPath) { return $null } $mappings = @( [pscustomobject]@{ Abs = [System.Environment]::GetFolderPath('ProgramFiles'); Vfs = 'VFS\ProgramFilesX64' } [pscustomobject]@{ Abs = [System.Environment]::GetFolderPath('ProgramFilesX86'); Vfs = 'VFS\ProgramFiles(x86)' } [pscustomobject]@{ Abs = [System.Environment]::GetFolderPath('System'); Vfs = 'VFS\SystemX64' } [pscustomobject]@{ Abs = [System.Environment]::GetFolderPath('Windows'); Vfs = 'VFS\Windows' } [pscustomobject]@{ Abs = [System.Environment]::GetFolderPath('CommonApplicationData'); Vfs = 'VFS\ProgramData' } ) foreach ($m in $mappings) { if ($AbsPath.StartsWith($m.Abs, [System.StringComparison]::OrdinalIgnoreCase)) { $rel = $AbsPath.Substring($m.Abs.Length).TrimStart('\') $vfsRel = "$($m.Vfs)\$rel" # SECURITY: reject path-traversal segments so a hostile Registry.dat # cannot map to a file outside the package workspace. if ($vfsRel -match '(^|[\\/])\.\.([\\/]|$)') { return $null } if (Test-Path -LiteralPath (Join-Path -Path $WorkspacePath -ChildPath $vfsRel)) { return $vfsRel } } } return $null } function _MsixRegPathToVfsRelative { <# .SYNOPSIS Translates an MSIX folder-variable DLL path (e.g. [{ProgramFilesX64}]\app\foo.dll) stored in Registry.dat to a VFS-relative path within an already-unpacked workspace. Falls through to _MsixAbsoluteToVfsRelativeDirect for plain absolute paths. Returns $null if the path cannot be mapped or the file is not present in the package. #> param([string]$RegPath, [string]$WorkspacePath) if (-not $RegPath) { return $null } # Folder-variable format: [{VarName}]\rest\of\path $varMappings = @( [pscustomobject]@{ Var = 'ProgramFilesX64'; Vfs = 'VFS\ProgramFilesX64' } [pscustomobject]@{ Var = 'ProgramFilesX86'; Vfs = 'VFS\ProgramFiles(x86)' } [pscustomobject]@{ Var = 'ProgramFiles6432'; Vfs = 'VFS\ProgramFilesX64' } [pscustomobject]@{ Var = 'System'; Vfs = 'VFS\SystemX64' } [pscustomobject]@{ Var = 'SystemX86'; Vfs = 'VFS\System' } [pscustomobject]@{ Var = 'Windows'; Vfs = 'VFS\Windows' } [pscustomobject]@{ Var = 'CommonAppData'; Vfs = 'VFS\ProgramData' } [pscustomobject]@{ Var = 'AppData'; Vfs = 'VFS\AppData\Roaming' } [pscustomobject]@{ Var = 'LocalAppData'; Vfs = 'VFS\AppData\Local' } ) foreach ($m in $varMappings) { if ($RegPath -match ('^\[\{' + [regex]::Escape($m.Var) + '\}\](.*)$')) { $rel = $Matches[1].TrimStart('\') $vfsRel = "$($m.Vfs)\$rel" # SECURITY: reject path-traversal segments so a hostile Registry.dat # cannot map to a file outside the package workspace. if ($vfsRel -match '(^|[\\/])\.\.([\\/]|$)') { return $null } if (Test-Path -LiteralPath (Join-Path -Path $WorkspacePath -ChildPath $vfsRel)) { return $vfsRel } # Return the mapping even if the file isn't present — caller can use for manifest return $vfsRel } } # Plain absolute path fallback return _MsixAbsoluteToVfsRelativeDirect -AbsPath $RegPath -WorkspacePath $WorkspacePath } function Get-MsixShellContextMenuEntry { <# .SYNOPSIS Scans the package's Registry.dat for shell verbs (Classes\*\shell\…) and shellex COM handlers (Classes\*\shellex\ContextMenuHandlers\…) that are invisible in File Explorer when outside the MSIX container. .DESCRIPTION Loads Registry.dat via reg.exe into a temporary HKCU hive (no elevation required) and extracts full key paths, CLSIDs, absolute DLL paths, and package-relative VFS paths. Returned objects have these properties: Type 'ShellVerb' or 'ShellExt' Target '*', 'Directory', 'Directory\Background', … VerbName (ShellVerb) the verb label, e.g. 'Open with Notepad++' HandlerName (ShellExt) handler key name, often same as display name Command (ShellVerb) the command string Clsid (ShellExt) GUID string e.g. '{AAAA-...}' DllPath (ShellExt) absolute InProcServer32 path VfsDllPath (ShellExt) package-relative VFS path if DLL found in pkg Uses offreg.dll (Offline Registry API) to parse Registry.dat in memory. No elevation required. Surfaces `ShellVerb` and `ShellExt` findings via Get-MsixHeuristicFinding; ShellExt entries with a resolved Clsid + VfsDllPath are auto-fixable through the `AddLegacyContextMenu` stage of Invoke-MsixAutoFixFromAnalysis. .PARAMETER PackagePath .msix file to inspect. .EXAMPLE # Surface all shell verbs and shellex handlers (no elevation required) Get-MsixShellContextMenuEntry -PackagePath app.msix | Format-Table Type, Target, VerbName, HandlerName, Clsid, VfsDllPath .OUTPUTS [pscustomobject[]] with Type, Target, VerbName/HandlerName, Command, Clsid, DllPath, VfsDllPath as documented above. #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)][string]$PackagePath, [string]$WorkspacePath ) $ws = _MsixResolveScanWorkspace -PackagePath $PackagePath -WorkspacePath $WorkspacePath -Label 'shellctx' $workspace = $ws.Path try { $datPath = Join-Path -Path $workspace -ChildPath 'Registry.dat' if (-not (Test-Path -LiteralPath $datPath)) { return @() } $results = [System.Collections.Generic.List[object]]::new() $clsidGuidRegex = '^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$' $hive = _MsixOpenOfflineHive -Path $datPath try { # Helper: read the InProcServer32 default value from either the # 64-bit Classes\CLSID branch or the WOW6432Node fallback. function _resolveClsidDll([IntPtr]$h, [string]$clsid) { foreach ($prefix in @( 'REGISTRY\MACHINE\SOFTWARE\Classes\CLSID', 'REGISTRY\MACHINE\SOFTWARE\WOW6432Node\Classes\CLSID' )) { $v = _MsixOfflineGetValue -Parent $h -SubKey "$prefix\$clsid\InProcServer32" -Name '' if ($v) { return $v } } return $null } # Targets the shell uses for context-menu handlers. 'Folder' (issue # #80) covers folders incl. 7-Zip's handler; 'Directory' is # filesystem dirs; 'Drive' covers drive roots. foreach ($target in @('*', 'Directory', 'Directory\Background', 'Folder', 'Drive', 'AllFilesystemObjects')) { $tgtClean = $target # ── Shell verbs: Classes\<target>\shell\<verb> $shellPath = "REGISTRY\MACHINE\SOFTWARE\Classes\$target\shell" $shellKey = _MsixOfflineOpenKey -Parent $hive -SubKey $shellPath if ($shellKey -ne [IntPtr]::Zero) { try { foreach ($verbName in (_MsixOfflineEnumSubKeys -Key $shellKey)) { $verbPath = "$shellPath\$verbName" $ech = _MsixOfflineGetValue -Parent $hive -SubKey $verbPath -Name 'ExplorerCommandHandler' if ($ech) { if ($ech -notmatch '^\{') { $ech = "{$ech}" } $dll = $null; $vfsDll = $null if ($ech -match $clsidGuidRegex) { $dll = _resolveClsidDll $hive $ech if ($dll) { $vfsDll = _MsixRegPathToVfsRelative -RegPath $dll -WorkspacePath $workspace } } $results.Add([pscustomobject]@{ Type = 'ShellExt' Target = $tgtClean HandlerName = $verbName Command = $null Clsid = $ech DllPath = $dll VfsDllPath = $vfsDll }) } else { $cmd = _MsixOfflineGetValue -Parent $hive -SubKey "$verbPath\command" -Name '' $results.Add([pscustomobject]@{ Type = 'ShellVerb' Target = $tgtClean VerbName = $verbName Command = $cmd Clsid = $null DllPath = $null VfsDllPath = $null }) } } } finally { _MsixOfflineCloseKey -Key $shellKey } } # ── shellex COM handlers: Classes\<target>\shellex\ContextMenuHandlers\<name> $shexPath = "REGISTRY\MACHINE\SOFTWARE\Classes\$target\shellex\ContextMenuHandlers" $shexKey = _MsixOfflineOpenKey -Parent $hive -SubKey $shexPath if ($shexKey -ne [IntPtr]::Zero) { try { foreach ($handlerName in (_MsixOfflineEnumSubKeys -Key $shexKey)) { $clsid = _MsixOfflineGetValue -Parent $hive -SubKey "$shexPath\$handlerName" -Name '' if ($clsid -and $clsid -notmatch '^\{') { $clsid = "{$clsid}" } $dll = $null; $vfsDll = $null if ($clsid -and $clsid -match $clsidGuidRegex) { $dll = _resolveClsidDll $hive $clsid if ($dll) { $vfsDll = _MsixRegPathToVfsRelative -RegPath $dll -WorkspacePath $workspace } } $results.Add([pscustomobject]@{ Type = 'ShellExt' Target = $tgtClean HandlerName = $handlerName Command = $null Clsid = $clsid DllPath = $dll VfsDllPath = $vfsDll }) } } finally { _MsixOfflineCloseKey -Key $shexKey } } # ── shellex COM handlers: Classes\<target>\shellex\DragDropHandlers\<name> $ddPath = "REGISTRY\MACHINE\SOFTWARE\Classes\$target\shellex\DragDropHandlers" $ddKey = _MsixOfflineOpenKey -Parent $hive -SubKey $ddPath if ($ddKey -ne [IntPtr]::Zero) { try { foreach ($handlerName in (_MsixOfflineEnumSubKeys -Key $ddKey)) { $clsid = _MsixOfflineGetValue -Parent $hive -SubKey "$ddPath\$handlerName" -Name '' if ($clsid -and $clsid -notmatch '^\{') { $clsid = "{$clsid}" } $dll = $null; $vfsDll = $null if ($clsid -and $clsid -match $clsidGuidRegex) { $dll = _resolveClsidDll $hive $clsid if ($dll) { $vfsDll = _MsixRegPathToVfsRelative -RegPath $dll -WorkspacePath $workspace } } $results.Add([pscustomobject]@{ Type = 'DragDrop' Target = $tgtClean HandlerName = $handlerName Command = $null Clsid = $clsid DllPath = $dll VfsDllPath = $vfsDll }) } } finally { _MsixOfflineCloseKey -Key $ddKey } } } } finally { _MsixCloseOfflineHive -Hive $hive } # Deduplicate $seen = @{} return @($results | Where-Object { $key = "$($_.Type)|$($_.Target)|$(if ($_.Type -eq 'ShellVerb') { $_.VerbName } else { $_.HandlerName })" if (-not $seen[$key]) { $seen[$key] = $true; $true } else { $false } }) } finally { if ($ws.Owned) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } } } #endregion #region COM server entries --------------------------------------------------- function Get-MsixComServerEntry { <# .SYNOPSIS Scans the package's Registry.dat for COM server registrations (CLSID\*\InProcServer32 and CLSID\*\LocalServer32) that may need to be declared in the manifest via com:Extension (windows.comServer). .DESCRIPTION When elevated, loads the hive via reg.exe for full extraction: CLSIDs, server type, DLL path, VFS-relative path (if the DLL lives in the package), and ThreadingModel. Works without elevation: hive is mounted under HKCU. Returned objects: Clsid '{XXXXXXXX-...}' ServerType 'InProc' | 'LocalServer' | 'Unknown' DllPath absolute InProcServer32 / LocalServer32 path (elevated) VfsDllPath package-relative VFS path if the DLL is in the package ThreadingModel e.g. 'Apartment' (InProc, elevated only) Surfaces the `ComServer` finding in Get-MsixHeuristicFinding. Entries with a resolved VfsDllPath feed the `AddComServer` stage of Invoke-MsixAutoFixFromAnalysis, which calls Add-MsixComServerExtension. .PARAMETER PackagePath .msix file to inspect. .EXAMPLE # Find COM servers registered in the package's Registry.dat Get-MsixComServerEntry -PackagePath app.msix | Where-Object ServerType -eq 'InProc' .OUTPUTS [pscustomobject[]] with Clsid, ServerType, DllPath, VfsDllPath, ThreadingModel as documented above. #> [CmdletBinding()] [OutputType([object[]])] param( [Parameter(Mandatory)][string]$PackagePath, [string]$WorkspacePath ) $ws = _MsixResolveScanWorkspace -PackagePath $PackagePath -WorkspacePath $WorkspacePath -Label 'comsrv' $workspace = $ws.Path try { $datPath = Join-Path -Path $workspace -ChildPath 'Registry.dat' if (-not (Test-Path -LiteralPath $datPath)) { return @() } $results = [System.Collections.Generic.List[object]]::new() $clsidGuidRegex = '^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$' $hive = _MsixOpenOfflineHive -Path $datPath try { foreach ($branch in @( 'REGISTRY\MACHINE\SOFTWARE\Classes\CLSID', 'REGISTRY\MACHINE\SOFTWARE\WOW6432Node\Classes\CLSID' )) { $branchKey = _MsixOfflineOpenKey -Parent $hive -SubKey $branch if ($branchKey -eq [IntPtr]::Zero) { continue } try { foreach ($clsid in (_MsixOfflineEnumSubKeys -Key $branchKey)) { if ($clsid -notmatch $clsidGuidRegex) { continue } # InProcServer32 (DLL) $ipBase = "$branch\$clsid\InProcServer32" $dll = _MsixOfflineGetValue -Parent $hive -SubKey $ipBase -Name '' if ($dll) { $thread = _MsixOfflineGetValue -Parent $hive -SubKey $ipBase -Name 'ThreadingModel' $vfsDll = _MsixAbsoluteToVfsRelativeDirect -AbsPath $dll -WorkspacePath $workspace $results.Add([pscustomobject]@{ Clsid = $clsid ServerType = 'InProc' DllPath = $dll VfsDllPath = $vfsDll ThreadingModel = if ($thread) { $thread } else { 'Apartment' } }) } # LocalServer32 (EXE) $lsCmd = _MsixOfflineGetValue -Parent $hive -SubKey "$branch\$clsid\LocalServer32" -Name '' if ($lsCmd) { $results.Add([pscustomobject]@{ Clsid = $clsid ServerType = 'LocalServer' DllPath = $lsCmd VfsDllPath = $null ThreadingModel = $null }) } } } finally { _MsixOfflineCloseKey -Key $branchKey } } } finally { _MsixCloseOfflineHive -Hive $hive } # Deduplicate by CLSID + ServerType $seen = @{} return @($results | Where-Object { $key = "$($_.Clsid)|$($_.ServerType)" if (-not $seen[$key]) { $seen[$key] = $true; $true } else { $false } }) } finally { if ($ws.Owned) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } } } #endregion #region Application execution alias auto-suggest --------------------------- function Get-MsixAliasCandidate { <# .SYNOPSIS Lists package executables that LOOK like good AppExecutionAlias candidates — top-level user-facing binaries, not vendored helpers. .DESCRIPTION Heuristic: - .exe under VFS\ProgramFiles* with a manifest entry pointing at it - skip msvcr*, vcredist*, setup*, install*, uninst* Feeds the `AppExecutionAlias` finding in Get-MsixHeuristicFinding. Pass the AppId values to Add-MsixAlias to register the suggestions. .PARAMETER PackagePath .msix to scan (read-only). .EXAMPLE # Surface alias candidates and add the first one with Add-MsixAlias $cands = Get-MsixAliasCandidate -PackagePath app.msix | Where-Object { -not $_.AlreadyHasAlias } Add-MsixAlias -PackagePath app.msix ` -AppIds $cands.AppId -SkipSigning .OUTPUTS [pscustomobject[]] each with AppId, Executable, SuggestAlias, AlreadyHasAlias. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$PackagePath, [string]$WorkspacePath ) $ws = _MsixResolveScanWorkspace -PackagePath $PackagePath -WorkspacePath $WorkspacePath -Label 'alias' $workspace = $ws.Path try { [xml]$manifest = Get-MsixManifest -Path "$workspace\AppxManifest.xml" $apps = @($manifest.Package.Applications.Application) $skipPatterns = @( '^msvcr','^msvcp','^vcruntime','^ucrtbase', '^vcredist','^setup','^install','^uninst', '^psf','^msix','^api-ms-win-' ) foreach ($app in $apps) { $exe = $app.Executable if (-not $exe) { continue } $leaf = ($exe.Split('\')[-1]).ToLower() $skip = $skipPatterns | Where-Object { $leaf -match $_ } if ($skip) { continue } $aliasName = ($leaf -replace '\.exe$','') + '.exe' $hasAlias = [bool]($app.Extensions.Extension.AppExecutionAlias.ExecutionAlias) [pscustomobject]@{ AppId = $app.Id Executable = $exe SuggestAlias = $aliasName AlreadyHasAlias = $hasAlias } } } finally { if ($ws.Owned) { Remove-Item -LiteralPath $workspace -Recurse -Force -ErrorAction SilentlyContinue } } } #endregion #region Static analysis adapter -------------------------------------------- function Get-MsixHeuristicFinding { <# .SYNOPSIS Runs every read-only TMEditX-style analyzer against a package and returns merged findings. Used by Get-MsixStaticAnalysis to expand the report. #> [CmdletBinding()] param([Parameter(Mandatory)][string]$PackagePath) $out = [System.Collections.Generic.List[object]]::new() # PERFORMANCE (#58): unpack the package ONCE here and hand the shared # workspace to every read-only scanner via -WorkspacePath, instead of each # scanner unpacking independently (~14 unpacks per analysis run before this). $toolsRoot = Get-MsixToolsRoot $fileinfo = Get-Item -LiteralPath $PackagePath $shared = New-MsixWorkspace -PackageName "$($fileinfo.BaseName)-scan" try { $r = Invoke-MsixProcess -FilePath "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $shared, '/o') Assert-MsixProcessSuccess -Result $r -Operation 'MakeAppx unpack' # Uninstaller artefacts foreach ($u in Get-MsixUninstallerCandidate -PackagePath $PackagePath -WorkspacePath $shared) { $out.Add([pscustomobject]@{ Severity = 'Warning' Category = 'UninstallerArtifact' Symptom = "Looks like a leftover installer artefact: $($u.Name)" Recommendation = "Remove-MsixUninstallerArtifact -PackagePath '$PackagePath'" Evidence = $u.Path AppId = $null }) } # Auto-updater artefacts (binaries + scheduled-task XMLs) try { foreach ($u in Get-MsixUpdaterCandidate -PackagePath $PackagePath -WorkspacePath $shared) { $out.Add([pscustomobject]@{ Severity = 'Info' Category = 'UpdaterArtifact' Symptom = "Auto-updater detected: $($u.LeafName) ($($u.Kind))" Recommendation = "Remove-MsixUpdaterArtifact -PackagePath '$PackagePath'" Evidence = $u.RelativePath AppId = $null }) } } catch { Write-MsixLog -Level Debug -Message "Updater heuristic skipped: $_" } # Plugin / extension-point directories. Default fix path is # selective FileSystemWriteVirtualization (Win10 19041+); operators on # older fleets can opt into PSF FileRedirection via -LegacyPluginFix on # Invoke-MsixAutoFixFromAnalysis. try { foreach ($p in Get-MsixPluginExtensionPoint -PackagePath $PackagePath -WorkspacePath $shared) { $out.Add([pscustomobject]@{ Severity = 'Info' Category = 'PluginDirectory' Symptom = "Likely runtime extension folder: $($p.Name) ($($p.FileCount) entries)" Recommendation = "Set-MsixFileSystemWriteVirtualization -PackagePath '$PackagePath' -ExcludedDirectories @('$(_MsixEscapeSingleQuote ($p.RelativePath -replace '\\','/'))') (modern: desktop6+virtualization carve-out) OR Add-MsixPsfV2 -Fixups (New-MsixPsfFileRedirectionConfig -Base '$(_MsixEscapeSingleQuote ($p.RelativePath -replace '\\','/'))' -Patterns '.*') (legacy: PSF route)" Evidence = $p.RelativePath AppId = $null }) } } catch { Write-MsixLog -Level Debug -Message "Plugin extension-point heuristic skipped: $_" } # Run keys foreach ($r in Get-MsixRunKeyEntry -PackagePath $PackagePath -WorkspacePath $shared) { $out.Add([pscustomobject]@{ Severity = 'Info' Category = 'RunKey' Symptom = "Package's $($r.Hive) ships an autostart Run entry." Recommendation = "Use a PSF startScript or an HKCU Run entry instead — packaged HKLM Run keys do not fire." Evidence = $r.Match AppId = $null }) } # Alias suggestions foreach ($a in Get-MsixAliasCandidate -PackagePath $PackagePath -WorkspacePath $shared) { if ($a.AlreadyHasAlias) { continue } $out.Add([pscustomobject]@{ Severity = 'Info' Category = 'AppExecutionAlias' Symptom = "$($a.AppId) has no AppExecutionAlias." Recommendation = "Add-MsixAlias -PackagePath '$PackagePath' -AppIds '$(_MsixEscapeSingleQuote $a.AppId)' (suggested alias: $($a.SuggestAlias))" Evidence = $a.Executable AppId = $a.AppId }) } # VC runtime missing try { $vc = Get-MsixVcRuntimeReference -PackagePath $PackagePath -WorkspacePath $shared if ($vc.Missing) { $out.Add([pscustomobject]@{ Severity = 'Warning' Category = 'VcRuntime' Symptom = "References VC runtime DLLs that are not bundled: $($vc.Missing -join ', ')" Recommendation = "Add-MsixVcRuntimeBundle -PackagePath '$PackagePath' -SourceFolder <vs-redist-folder>" Evidence = $vc.Missing -join ', ' AppId = $null }) } } catch { Write-MsixLog -Level Debug -Message "VC runtime heuristic skipped: $_" } # ── Fonts inside the package (suggest uap4:SharedFonts) ──────────────── try { $fonts = Get-MsixFontCandidate -PackagePath $PackagePath -WorkspacePath $shared if ($fonts) { $out.Add([pscustomobject]@{ Severity = 'Info' Category = 'ManifestFix:SharedFonts' Symptom = "Package ships $($fonts.Count) font file(s) but doesn't register them via uap4:SharedFonts." Recommendation = "Add-MsixFontExtension -PackagePath '$PackagePath' -FontPaths (Get-MsixFontCandidate -PackagePath '$PackagePath' | Select-Object -ExpandProperty Path)" Evidence = ($fonts | Select-Object -First 5 -ExpandProperty Name) -join ', ' AppId = $null }) } } catch { Write-MsixLog -Level Debug -Message "Font heuristic skipped: $_" } # ── Desktop shortcuts inside the package (suggest removal) ────────────── try { $sc = Get-MsixDesktopShortcutCandidate -PackagePath $PackagePath -WorkspacePath $shared if ($sc) { $out.Add([pscustomobject]@{ Severity = 'Warning' Category = 'DesktopShortcuts' Symptom = "Package ships $($sc.Count) .lnk file(s) under VFS\Common Desktop / VFS\Desktop." Recommendation = "Remove-MsixDesktopShortcut -PackagePath '$PackagePath'" Evidence = ($sc | Select-Object -First 3 -ExpandProperty Name) -join ', ' AppId = $null }) } } catch { Write-MsixLog -Level Debug -Message "Desktop shortcut heuristic skipped: $_" } # ── Capability hints from PE imports (suggest Add-MsixCapability) ─────── try { $caps = Get-MsixCapabilityHint -PackagePath $PackagePath -WorkspacePath $shared if ($caps) { $out.Add([pscustomobject]@{ Severity = 'Info' Category = 'CapabilityHints' Symptom = "PE imports suggest the app may need: $($caps -join ', ')" Recommendation = "Add-MsixCapability -PackagePath '$PackagePath' -Names $($caps -join ',') (validate with Application Capability Profiler first)" Evidence = $caps -join ', ' AppId = $null }) } } catch { Write-MsixLog -Level Debug -Message "Capability hints heuristic skipped: $_" } # ── Uninstall registry leftovers ──────────────────────────────────────── try { $uninst = Get-MsixUninstallRegistryEntry -PackagePath $PackagePath -WorkspacePath $shared if ($uninst) { $out.Add([pscustomobject]@{ Severity = 'Warning' Category = 'UninstallRegistry' Symptom = "Package's Registry.dat has $($uninst.Count) Uninstall\* leftover key(s)." Recommendation = "Remove-MsixUninstallerArtifact -PackagePath '$PackagePath' (strips Uninstall\* keys from Registry.dat via offreg; no elevation required)" Evidence = ($uninst | Select-Object -First 3 -ExpandProperty DisplayName) -join ', ' AppId = $null }) } } catch { Write-MsixLog -Level Debug -Message "Uninstall registry heuristic skipped: $_" } # ── Shell context-menu entries invisible outside the MSIX container ─────── try { $shellMenus = Get-MsixShellContextMenuEntry -PackagePath $PackagePath -WorkspacePath $shared $verbEntries = @($shellMenus | Where-Object Type -eq 'ShellVerb') $shellextEntries = @($shellMenus | Where-Object Type -eq 'ShellExt') if ($verbEntries) { $out.Add([pscustomobject]@{ Severity = 'Warning' Category = 'ShellVerb' Symptom = "Registry.dat declares $($verbEntries.Count) shell verb(s) ($($verbEntries.VerbName -join ', ')) that are invisible in File Explorer outside the MSIX container." Recommendation = "Plain command shell verbs require a COM surrogate to surface in File Explorer under MSIX. Convert the command to a COM in-process server, then call: Add-MsixLegacyContextMenu -PackagePath '$PackagePath' -ShellExtDll <VFS-dll> -Clsid <new-guid> -DisplayName '<verb>' -FileTypes '*' (desktop9:fileExplorerClassicContextMenuHandler). Note: Add-MsixShellVerbExtension generates uap3:SupportedVerbs which is for Open-With file-type associations, NOT for context menu entries." Evidence = ($verbEntries | ForEach-Object { "$($_.Target)\shell\$($_.VerbName)" }) -join '; ' AppId = $null ShellEntries = $verbEntries }) } if ($shellextEntries) { $out.Add([pscustomobject]@{ Severity = 'Error' Category = 'ShellExt' Symptom = "Registry.dat declares $($shellextEntries.Count) in-process shell handler(s) ($($shellextEntries.HandlerName -join ', ')) that will not load under MSIX. Includes both shellex\ContextMenuHandlers and shell verb keys with ExplorerCommandHandler (COM-delegating verbs)." Recommendation = "Add-MsixLegacyContextMenu -PackagePath '$PackagePath' -ShellExtDll <VFS-relative-dll> -Clsid <clsid> -DisplayName <name> (Win11 21H2+: desktop9:fileExplorerClassicContextMenuHandler + com:SurrogateServer)" Evidence = ($shellextEntries | ForEach-Object { $regPath = if ($_.Clsid -and ($_.DllPath -or $_.VfsDllPath)) { # ExplorerCommandHandler verb or shellex entry with resolved CLSID "$($_.Target)\shell\$($_.HandlerName) [ExplorerCommandHandler=$($_.Clsid)]$(if ($_.VfsDllPath) { " -> $($_.VfsDllPath)" })" } else { "$($_.Target)\shellex\ContextMenuHandlers\$($_.HandlerName)$(if ($_.Clsid) { " [$($_.Clsid)]" })" } $regPath }) -join '; ' AppId = $null ShellEntries = $shellextEntries }) } } catch { Write-MsixLog -Level Debug -Message "Shell context-menu heuristic failed: $_" } # ── COM server registrations in Registry.dat ────────────────────────────── try { $comEntries = Get-MsixComServerEntry -PackagePath $PackagePath -WorkspacePath $shared # Only surface InProc servers with a resolvable VFS DLL (package-bundled); # LocalServer and Unknown-type entries can't be auto-fixed and produce noise. $inprocPkg = @($comEntries | Where-Object { $_.ServerType -eq 'InProc' -and $_.VfsDllPath }) if ($inprocPkg) { $out.Add([pscustomobject]@{ Severity = 'Info' Category = 'ComServer' Symptom = "Registry.dat registers $($inprocPkg.Count) in-process COM server(s) with DLLs inside the package. External COM clients cannot activate them without a com:Extension declaration in the manifest." Recommendation = "Add-MsixComServerExtension -PackagePath '$PackagePath' -Servers @($($inprocPkg | ForEach-Object { "@{ Clsid='$(_MsixEscapeSingleQuote $_.Clsid)'; VfsDllPath='$(_MsixEscapeSingleQuote $_.VfsDllPath)'; ThreadingModel='$(_MsixEscapeSingleQuote $_.ThreadingModel)' }" } | Select-Object -First 2 | Join-String -Separator ', '))" Evidence = ($inprocPkg | ForEach-Object { "$($_.Clsid) → $($_.VfsDllPath)" }) -join '; ' AppId = $null ComEntries = $inprocPkg }) } } catch { Write-MsixLog -Level Debug -Message "COM server heuristic failed: $_" } # ── Nested installer packages inside the package ───────────────────────── try { $nested = @(Get-MsixNestedPackageCandidate -PackagePath $PackagePath -WorkspacePath $shared) if ($nested) { $out.Add([pscustomobject]@{ Severity = 'Warning' Category = 'NestedPackage' Symptom = "Package contains $($nested.Count) nested installer package(s) that cannot be installed from within the MSIX container." Recommendation = 'Remove these files and deploy the nested packages separately (Intune / SCCM staging, or a startScript wrapper that calls winget / msiexec on a staged copy).' Evidence = ($nested | Select-Object -First 3 -ExpandProperty Name) -join ', ' AppId = $null }) } } catch { Write-MsixLog -Level Debug -Message "Nested package heuristic failed: $_" } # ── Manifest-level findings (alternatives to PSF) ─────────────────────── # Reuses the shared workspace unpacked above (no separate unpack). try { $manifestFile = Join-Path -Path $shared -ChildPath 'AppxManifest.xml' if (Test-Path -LiteralPath $manifestFile) { [xml]$mf = Get-MsixManifest -Path $manifestFile $exts = @($mf.Package.Extensions.Extension) $appExts = @($mf.Package.Applications.Application.Extensions.Extension) # FileSystem/RegistryWriteVirtualization live in <Properties> (desktop6 namespace), # NOT in <Extensions>. Check for the flag element by local name + namespace-uri. $d6Uri = 'http://schemas.microsoft.com/appx/manifest/desktop/windows10/6' $hasFsVirt = [bool]($mf.Package.Properties.SelectSingleNode( "*[local-name()='FileSystemWriteVirtualization' and namespace-uri()='$d6Uri']")) $hasRegVirt = [bool]($mf.Package.Properties.SelectSingleNode( "*[local-name()='RegistryWriteVirtualization' and namespace-uri()='$d6Uri']")) $hasInstVirt = ($exts.Category -contains 'windows.installedLocationVirtualization') # LoaderSearchPathOverride can be at Package-level OR Application-level # (the function was moved to Application-level; check both for idempotency). $hasLoaderOv = $false foreach ($e in (@($exts) + @($appExts))) { if ($e.SelectSingleNode('*[local-name()="LoaderSearchPathOverride"]')) { $hasLoaderOv = $true; break } } # If we already detected write-permission risk via static or # trace findings AND the package isn't using the manifest fix, # surface that as a more lightweight alternative to PSF. $needsWriteFix = $out | Where-Object Category -in 'FileRedirectionFixup','UninstallerArtifact' if ($needsWriteFix -and -not $hasFsVirt -and -not $hasInstVirt) { $out.Add([pscustomobject]@{ Severity = 'Info' Category = 'ManifestFix:FileSystemWriteVirtualization' Symptom = 'Package writes to its install location but no manifest virtualization extension is set.' Recommendation = "Set-MsixFileSystemWriteVirtualization -PackagePath '$PackagePath' (Win10 19041+; alternative to PSF FileRedirectionFixup for the broad case)" Evidence = 'No desktop6:FileSystemWriteVirtualization in <Properties>' AppId = $null }) } $needsRegFix = $out | Where-Object Category -eq 'RegLegacyFixups' if ($needsRegFix -and -not $hasRegVirt) { $out.Add([pscustomobject]@{ Severity = 'Info' Category = 'ManifestFix:RegistryWriteVirtualization' Symptom = 'Package writes to HKLM but no manifest registry virtualization is set.' Recommendation = "Set-MsixRegistryWriteVirtualization -PackagePath '$PackagePath' (Win10 19041+; alternative to RegLegacy Hklm2Hkcu)" Evidence = 'No desktop6:RegistryWriteVirtualization in <Properties>' AppId = $null }) } # HKLM Run keys but no startupTask extension $hasStartupTask = $false foreach ($e in $appExts) { if ($e.Category -eq 'windows.startupTask') { $hasStartupTask = $true; break } } $hasRunKeys = $out | Where-Object Category -eq 'RunKey' if ($hasRunKeys -and -not $hasStartupTask) { $out.Add([pscustomobject]@{ Severity = 'Info' Category = 'ManifestFix:StartupTask' Symptom = 'Package contains autostart Run keys but declares no windows.startupTask.' Recommendation = "Add-MsixStartupTask -PackagePath '$PackagePath' -AppId <app> -TaskId <id> -DisplayName <name>" Evidence = 'Run-key entries in Registry.dat / User.dat' AppId = $null }) } # DLL load failures suggest LoaderSearchPathOverride $dllFindings = $out | Where-Object Category -eq 'DynamicLibraryFixup' if ($dllFindings -and -not $hasLoaderOv) { $out.Add([pscustomobject]@{ Severity = 'Info' Category = 'ManifestFix:LoaderSearchPathOverride' Symptom = 'DLL load failures reported but no uap6:LoaderSearchPathOverride is set.' Recommendation = "Add-MsixLoaderSearchPathOverride -PackagePath '$PackagePath' -Paths 'VFS/ProgramFilesX64/<App>/lib' (manifest alternative to DynamicLibraryFixup)" Evidence = 'LoadLibrary failure(s) in trace output' AppId = $null }) } # Suppress ShellExt finding if the manifest already declares desktop9 COM handlers. # desktop9 extensions are at Application-level ($appExts); check both levels for safety. $hasLegacyCtxMenu = (@($exts) + @($appExts)) | Where-Object { $_.Category -in @('windows.fileExplorerClassicContextMenuHandler','windows.fileExplorerClassicDragDropContextMenuHandler') } if ($hasLegacyCtxMenu) { $toRemove = @($out | Where-Object Category -eq 'ShellExt') foreach ($rem in $toRemove) { [void]$out.Remove($rem) } } # Suppress ShellVerb finding if the manifest already declares FTA or IExplorerCommand menus $hasFta = $appExts | Where-Object { $_.Category -eq 'windows.fileTypeAssociation' } $hasModernCtxMenu = $appExts | Where-Object { $_.Category -eq 'windows.fileExplorerContextMenus' } if ($hasFta -or $hasModernCtxMenu) { $toRemove = @($out | Where-Object Category -eq 'ShellVerb') foreach ($rem in $toRemove) { [void]$out.Remove($rem) } } # Suppress ComServer finding if all CLSIDs are already declared in the manifest $comFinding = @($out | Where-Object Category -eq 'ComServer') if ($comFinding) { $declaredClsids = @($mf.SelectNodes("//*[local-name()='Class']/@Id") | ForEach-Object { $_.Value }) $stillMissing = @($comFinding[0].ComEntries | Where-Object { $_.Clsid -notin $declaredClsids }) if (-not $stillMissing) { [void]$out.Remove($comFinding[0]) } elseif ($stillMissing.Count -lt $comFinding[0].ComEntries.Count) { # Partial: update the finding to only the missing ones $comFinding[0].ComEntries = $stillMissing } } } } catch { Write-MsixLog -Level Debug -Message "Manifest-fix heuristic failed: $_" } } finally { # The single shared workspace is always cleaned up here. Remove-Item -LiteralPath $shared -Recurse -Force -ErrorAction SilentlyContinue } return $out } #endregion # --------------------------------------------------------------------------- # Plural-noun back-compat aliases (issue #38: preserved across the heuristics # file split — every alias defined in the pre-split MSIX.Heuristics.ps1 still # resolves to the same singular cmdlet). # --------------------------------------------------------------------------- Set-Alias Get-MsixUninstallerCandidates Get-MsixUninstallerCandidate Set-Alias Get-MsixUninstallRegistryEntries Get-MsixUninstallRegistryEntry Set-Alias Get-MsixUpdaterCandidates Get-MsixUpdaterCandidate Set-Alias Get-MsixRunKeyEntries Get-MsixRunKeyEntry Set-Alias Get-MsixShellContextMenuEntries Get-MsixShellContextMenuEntry Set-Alias Get-MsixComServerEntries Get-MsixComServerEntry Set-Alias Get-MsixAliasCandidates Get-MsixAliasCandidate Set-Alias Get-MsixHeuristicFindings Get-MsixHeuristicFinding |