MSIX.Investigation.ps1

# =============================================================================
# MSIX Investigation Engine
# -----------------------------------------------------------------------------
# Automates the manual procedure documented at:
# - psf/package-support-framework (overall flow)
# - psf/psf-current-working-directory (Name Not Found under SysWOW64)
# - psf/psf-filesystem-writepermission (Access Denied / Generic Write
# under Program Files\WindowsApps)
# =============================================================================

# Mapping from observed failure pattern -> recommended fixup name
$script:FailurePatternMap = @(
    @{
        Name      = 'WorkingDirectory'
        Pattern   = 'Name not found.*(System32|SysWOW64)'
        Fixup     = 'WorkingDirectory'
        Reason    = 'App reads files from CWD but CWD defaults to System32/SysWOW64.'
    },
    @{
        Name      = 'WriteToPackage'
        Pattern   = 'Access denied.*WindowsApps'
        Fixup     = 'FileRedirectionFixup'
        Reason    = 'App writes inside Program Files\WindowsApps (read-only).'
    },
    @{
        Name      = 'WriteToProgramFiles'
        Pattern   = 'Access denied.*Program Files'
        Fixup     = 'FileRedirectionFixup'
        Reason    = 'App writes to Program Files (denied for non-elevated).'
    },
    @{
        Name      = 'RegistryWrite'
        Pattern   = 'Access denied.*HKLM'
        Fixup     = 'RegLegacyFixups'
        Reason    = 'App requests write/full access to HKLM keys.'
    }
)


function Add-MsixDiagnosticTrace {
    <#
    .SYNOPSIS
        Injects TraceFixup into a package with allFailures levels for diagnostics.
        Output is sent to the attached debugger / DebugView.
 
    .DESCRIPTION
        Equivalent to manually adding the TraceFixup snippet from the PSF docs.
        The package is repacked and re-signed; install it, run the app, and view
        output in DebugView (https://learn.microsoft.com/sysinternals/downloads/debugview).
 
    .PARAMETER PackagePath
        Path to the .msix to instrument.
 
    .PARAMETER OutputPath
        Write the modified package here instead of overwriting -PackagePath.
 
    .PARAMETER SkipSigning
        Do not sign the resulting package.
 
    .PARAMETER Pfx / PfxPassword
        Signing certificate. Omit for /a (auto store).
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory)]
        [string]$PackagePath,
        [string]$OutputPath,
        [Alias('NoSign')]
        [switch]$SkipSigning,
        [string]$Pfx,
        [SecureString]$PfxPassword
    )

    if (-not $PSCmdlet.ShouldProcess($PackagePath, 'Add Diagnostic Trace')) { return }

    $trace = New-MsixPsfTraceConfig -FilesystemLevel 'allFailures' -RegistryLevel 'allFailures'
    Add-MsixPsfV2 -PackagePath $PackagePath -Fixups @($trace) `
                  -OutputPath $OutputPath -SkipSigning:$SkipSigning `
                  -Pfx $Pfx -PfxPassword $PfxPassword
}


function Resolve-MsixProcMonPath {
    <#
    .SYNOPSIS
        Finds procmon.exe (Sysinternals Process Monitor). Order:
        $env:MSIX_PROCMON_PATH > PATH > C:\PSF\ProcessMonitor > Sysinternals install dirs.
    #>

    [CmdletBinding()]
    param()

    if ($env:MSIX_PROCMON_PATH -and (Test-Path $env:MSIX_PROCMON_PATH)) {
        return (Resolve-Path $env:MSIX_PROCMON_PATH).Path
    }
    $cmd = Get-Command procmon.exe, procmon64.exe -ErrorAction SilentlyContinue | Select-Object -First 1
    if ($cmd) { return $cmd.Source }

    foreach ($p in @(
        'C:\PSF\ProcessMonitor\Procmon.exe',
        'C:\PSF\ProcessMonitor\Procmon64.exe',
        "${env:ProgramFiles}\SysInternals\Procmon.exe",
        "${env:ProgramFiles}\SysInternalsSuite\Procmon.exe"
    )) {
        if (Test-Path $p) { return $p }
    }
    return $null
}


function Invoke-MsixProcMonCapture {
    <#
    .SYNOPSIS
        Launches a packaged app under Process Monitor, captures filtered failure
        events into a PML file, and returns the PML path.
 
    .PARAMETER PackageFamilyName
        e.g. 'Contoso.App_8wekyb3d8bbwe' (from Get-AppxPackage).
 
    .PARAMETER AppId
        Application Id from the manifest.
 
    .PARAMETER OutputPml
        Path for the captured PML log (default: temp).
 
    .PARAMETER DurationSeconds
        How long to capture before terminating procmon.
 
    .PARAMETER ProcessName
        Optional process name to filter for (improves signal-to-noise).
 
    .NOTES
        Requires Sysinternals Process Monitor on PATH or at C:\PSF\ProcessMonitor.
        See https://learn.microsoft.com/sysinternals/downloads/procmon
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$PackageFamilyName,
        [Parameter(Mandatory)]
        [string]$AppId,
        [string]$OutputPml = (Join-Path $env:TEMP "msix-procmon-$([guid]::NewGuid().ToString('N').Substring(0,8)).pml"),
        [int]$DurationSeconds = 30,
        [string]$ProcessName
    )
    $procmon = Resolve-MsixProcMonPath
    if (-not $procmon) {
        throw 'Process Monitor (procmon.exe) not found. Set $env:MSIX_PROCMON_PATH or place it on PATH.'
    }

    Write-MsixLog Info "Starting Process Monitor capture: $OutputPml"

    # Pre-configure filter rules in registry before launch (best-effort)
    if ($ProcessName) {
        $filtered = Set-MsixProcMonFilterRule -ProcessNames @($ProcessName)
        if ($filtered) {
            Write-MsixLog Info "Procmon filter set: Process Name is '$ProcessName'"
        } else {
            Write-MsixLog Warning "Could not pre-set Procmon filter; set manually: Process Name is '$ProcessName'"
        }
    }

    # Procmon CLI: /AcceptEula /Quiet /Minimized /BackingFile <pml> /Runtime <sec>
    $procmonArgs = @('/AcceptEula', '/Quiet', '/Minimized', '/BackingFile', "`"$OutputPml`"")
    Start-Process -FilePath $procmon -ArgumentList $procmonArgs -WindowStyle Minimized

    # Allow procmon to start
    Start-Sleep -Seconds 2

    Write-MsixLog Info "Launching $PackageFamilyName!$AppId"
    Invoke-CommandInDesktopPackage -PackageFamilyName $PackageFamilyName `
                                   -AppId $AppId `
                                   -Command 'cmd.exe' `
                                   -PreventBreakaway `
                                   -ErrorAction SilentlyContinue

    Start-Sleep -Seconds $DurationSeconds

    Write-MsixLog Info "Stopping Process Monitor capture"
    Start-Process -FilePath $procmon -ArgumentList @('/Terminate') -Wait

    if (-not (Test-Path $OutputPml)) {
        throw "Procmon capture failed; PML not created: $OutputPml"
    }
    return $OutputPml
}


function Get-MsixProcMonFailure {
    <#
    .SYNOPSIS
        Converts a Procmon PML log to CSV via procmon.exe and returns failure
        rows (Result != SUCCESS) parsed into objects.
 
    .PARAMETER PmlPath
        Path to the .pml file produced by Invoke-MsixProcMonCapture.
 
    .PARAMETER ProcessName
        Optional filter on Process Name column.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$PmlPath,
        [string]$ProcessName
    )

    if (-not (Test-Path $PmlPath)) { throw "PML not found: $PmlPath" }

    $procmon = Resolve-MsixProcMonPath
    if (-not $procmon) { throw 'procmon.exe not found.' }

    $csv = [System.IO.Path]::ChangeExtension($PmlPath, '.csv')
    Write-MsixLog Info "Converting PML -> CSV: $csv"

    $null = Invoke-MsixProcess $procmon -ArgumentList @('/OpenLog', $PmlPath, '/SaveAs', $csv, '/SaveApplyFilter', '/Quiet', '/Terminate')
    if (-not (Test-Path $csv)) {
        throw "Procmon failed to export CSV from $PmlPath"
    }

    $rows = Import-Csv -Path $csv
    if ($ProcessName) {
        $rows = $rows | Where-Object { $_.'Process Name' -like "*$ProcessName*" }
    }

    return $rows | Where-Object { $_.Result -and $_.Result -ne 'SUCCESS' }
}


function Get-MsixStaticAnalysis {
    <#
    .SYNOPSIS
        Inspects an MSIX package without running it and returns a list of
        likely PSF-fixable issues.
 
    .DESCRIPTION
        Static heuristics:
          - Working-directory mismatch (executable in subfolder, no PSF wrap)
          - Hardcoded log/config files in same dir as exe (write-permission risk)
          - .ini files inside VFS\ProgramFilesX64 (write-permission risk)
          - Missing PSF when the manifest has multiple Applications with shared dir
          - Sniff for known problematic launchers (registered, but no fixups)
          - Detect existing PSF integration so re-runs are idempotent
 
    .PARAMETER PackagePath
        .msix file to analyse (read-only; not modified).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$PackagePath
    )

    $toolsRoot = Get-MsixToolsRoot
    $fileinfo  = Get-Item $PackagePath
    $workspace = New-MsixWorkspace "$($fileinfo.BaseName)-static"
    $findings  = @()

    try {
        $r = Invoke-MsixProcess "$toolsRoot\Tools\MakeAppx.exe" -ArgumentList @('unpack', '/p', $fileinfo.FullName, '/d', $workspace, '/o')
        Assert-MsixProcessSuccess $r 'MakeAppx unpack'

        $null = Test-MsixManifest "$workspace\AppxManifest.xml"
        [xml]$manifest = Get-MsixManifest "$workspace\AppxManifest.xml"
        $apps = @($manifest.Package.Applications.Application)

        # Detect existing PSF.
        # Use GetAttribute() rather than the PowerShell XML shorthand to avoid member-enumeration
        # edge cases when the manifest has a single Application element (shorthand returns a scalar;
        # -match on a scalar yields $true/$false whose .Count is always 1, creating false positives).
        # Also require config.json to be present — PsfLauncher without config.json is not valid PSF.
        $psfApps = @($apps | Where-Object { $_.GetAttribute('Executable') -match 'PsfLauncher' })
        $hasPsf  = $psfApps.Count -gt 0
        if ($hasPsf) {
            $cfgJson = @(Get-ChildItem $workspace -Recurse -Filter 'config.json' -ErrorAction SilentlyContinue)
            $hasPsf  = $cfgJson.Count -gt 0   # bare PsfLauncher with no config.json is not real PSF
        }
        if ($hasPsf) {
            $findings += [pscustomobject]@{
                Severity     = 'Info'
                Category     = 'PSF'
                Symptom      = 'Package already wraps applications with PsfLauncher.'
                Recommendation = 'Inspect existing config.json before adding more fixups.'
                AppId        = ($psfApps | ForEach-Object { $_.GetAttribute('Id') }) -join ','
            }
        }

        foreach ($app in $apps) {
            $exe = $app.Executable
            if (-not $exe) { continue }

            # Working directory heuristic: exe lives in a subfolder
            if ($exe.Contains('\') -and -not $hasPsf) {
                $relDir = $exe.Substring(0, $exe.LastIndexOf('\'))
                $exeFs  = Join-Path $workspace $exe
                if (Test-Path $exeFs) {
                    $companions = Get-ChildItem (Split-Path $exeFs) -File -ErrorAction SilentlyContinue |
                                  Where-Object { $_.Extension -in '.ini', '.cfg', '.config', '.txt', '.dat', '.dll' }
                    if ($companions) {
                        $findings += [pscustomobject]@{
                            Severity        = 'Warning'
                            Category        = 'WorkingDirectory'
                            Symptom         = "Executable depends on companion files in $relDir but no workingDirectory set."
                            Recommendation  = "Add PSF with workingDirectory='$($relDir.Replace('\','/'))/'."
                            AppId           = $app.Id
                            Evidence        = ($companions | Select-Object -First 5 -ExpandProperty Name) -join ', '
                        }
                    }
                }
            }

            # Write-permission heuristic: log/cache/data files shipped under VFS
            $appDir = if ($exe.Contains('\')) { Join-Path $workspace ($exe.Substring(0, $exe.LastIndexOf('\'))) } else { $workspace }
            if (Test-Path $appDir) {
                $writableHints = Get-ChildItem $appDir -Recurse -File -ErrorAction SilentlyContinue |
                                 Where-Object { $_.Extension -in '.log', '.tmp', '.cache' -or
                                                $_.Name -match '^(settings|user|state)\.' }
                if ($writableHints) {
                    $base = ($exe.Substring(0, $exe.LastIndexOf('\'))).Replace('\','/') + '/'
                    $findings += [pscustomobject]@{
                        Severity        = 'Warning'
                        Category        = 'ManifestFix:FileSystemWriteVirtualization'
                        Symptom         = 'Writable-looking files shipped inside the VFS payload.'
                        Recommendation  = "Preferred (Win11+): Set-MsixFileSystemWriteVirtualization -PackagePath '$PackagePath' | Alternative: Apply FileRedirectionFixup -Base '$base' -Patterns '.*\.log','.*\.tmp'"
                        AppId           = $app.Id
                        Evidence        = ($writableHints | Select-Object -First 5 -ExpandProperty Name) -join ', '
                    }
                }
            }
        }

        # Multi-app shared folder
        if ($apps.Count -gt 1 -and -not $hasPsf) {
            $findings += [pscustomobject]@{
                Severity        = 'Info'
                Category        = 'MultiApp'
                Symptom         = "Package contains $($apps.Count) Applications."
                Recommendation  = 'PSF will create one PsfLauncher per app; ensure all of them are listed in config.json applications[].'
                AppId           = ($apps.Id -join ',')
            }
        }

        # Merge in TMEditX-style heuristic findings (uninstaller artefacts,
        # Run keys, alias suggestions, missing VC runtimes). Defined in
        # MSIX.Heuristics.ps1 — same module scope, so just call it.
        if (Get-Command Get-MsixHeuristicFinding -ErrorAction SilentlyContinue) {
            try {
                $heuristicFindings = Get-MsixHeuristicFinding -PackagePath $PackagePath
                if ($heuristicFindings) { $findings += @($heuristicFindings) }
            } catch {
                Write-MsixLog Debug "Heuristics raised: $_"
            }
        }

        return $findings

    } finally {
        Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue
    }
}


function Get-MsixCompatibilityReport {
    <#
    .SYNOPSIS
        Combines static analysis with optional procmon failures and produces a
        single report object with recommended fixup hashtables ready to feed
        into Add-MsixPsfV2 / Invoke-MsixPipeline.
 
    .PARAMETER PackagePath
        .msix file.
 
    .PARAMETER PmlPath
        Optional procmon log captured with Invoke-MsixProcMonCapture.
 
    .PARAMETER ProcessName
        Optional procmon process-name filter.
 
    .OUTPUTS
        [pscustomobject] with Findings (array) and SuggestedFixups (hashtable[]).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$PackagePath,
        [string]$PmlPath,
        [string]$TraceLogPath,
        [string]$ProcessName
    )

    Write-MsixLog Info "Static analysis: $PackagePath"
    $static = Get-MsixStaticAnalysis -PackagePath $PackagePath

    # ── Dynamic: trace fixup output (DebugView log) ──
    $traceFindings = @()
    if ($TraceLogPath) {
        Write-MsixLog Info "Trace analysis: $TraceLogPath"
        $traceFindings = @(Get-MsixTraceFailure -Path $TraceLogPath | ConvertFrom-MsixTraceToFinding)
    }

    $dynamic = @()
    if ($PmlPath) {
        Write-MsixLog Info "Dynamic analysis: $PmlPath"
        $failures = Get-MsixProcMonFailure -PmlPath $PmlPath -ProcessName $ProcessName
        foreach ($f in $failures) {
            foreach ($map in $script:FailurePatternMap) {
                $combined = "$($f.Result) $($f.Path) $($f.Detail)"
                if ($combined -match $map.Pattern) {
                    $dynamic += [pscustomobject]@{
                        Severity       = 'Error'
                        Category       = $map.Fixup
                        Symptom        = "$($f.Operation) on '$($f.Path)' returned $($f.Result)"
                        Recommendation = $map.Reason
                        AppId          = $null
                        Evidence       = "$($f.'Process Name'): $($f.Operation)"
                    }
                    break
                }
            }
        }
    }

    $allFindings = @($static) + @($dynamic) + @($traceFindings)

    # Synthesise concrete fixup hashtables for the ones we have enough info for
    $suggested = @()
    foreach ($f in $allFindings) {
        switch ($f.Category) {
            'WorkingDirectory' {
                # We don't add a fixup; this is handled via -WorkingDirectory in Add-MsixPsfV2
            }
            'FileRedirectionFixup' {
                # Pull base from the recommendation text
                if ($f.Recommendation -match "-Base '([^']+)'") {
                    $base = $matches[1]
                    $suggested += New-MsixPsfFileRedirectionConfig -Base $base -Patterns '.*\.log', '.*\.tmp', '.*\.cache'
                }
            }
            'RegLegacyFixups' {
                $suggested += New-MsixPsfRegLegacyConfig -Hive HKLM -Access Full2MaxAllowed -Patterns 'SOFTWARE\*'
            }
        }
    }

    # Synthesise manifest-only alternatives
    $manifestAlternatives = @()
    foreach ($f in $allFindings) {
        switch ($f.Category) {
            'FileRedirectionFixup' {
                $manifestAlternatives += [pscustomobject]@{
                    Cmdlet = 'Set-MsixFileSystemWriteVirtualization'
                    Reason = 'Redirects writes to install dir without PSF (Win11+)'
                }
            }
            'RegLegacyFixups' {
                $manifestAlternatives += [pscustomobject]@{
                    Cmdlet = 'Set-MsixRegistryWriteVirtualization'
                    Reason = 'Redirects HKLM writes without PSF (Win11+)'
                }
            }
        }
    }

    $report = [pscustomobject]@{
        PackagePath            = $PackagePath
        Findings               = $allFindings
        SuggestedFixups        = ($suggested | Select-Object -Unique)
        SuggestedManifestFixes = ($manifestAlternatives | Select-Object -Unique)
        ProcMonLog             = $PmlPath
        RecommendedCommands    = $null
    }

    # Generate copy-paste-ready PowerShell for the operator. Defined in
    # MSIX.Debug.ps1 and is dot-sourced into the same module scope.
    if (Get-Command Get-MsixDebugRecommendation -ErrorAction SilentlyContinue) {
        $report.RecommendedCommands = Get-MsixDebugRecommendation -Report $report -PackagePath $PackagePath
    }

    return $report
}


function Invoke-MsixInvestigation {
    <#
    .SYNOPSIS
        End-to-end investigation orchestrator. Runs static analysis (always) and,
        if the package is installed, optionally drives procmon + parses results.
 
    .PARAMETER PackagePath
        .msix file to investigate.
 
    .PARAMETER WithProcMon
        Also capture a runtime trace under Process Monitor. Requires the package
        to be installed and procmon.exe available.
 
    .PARAMETER PackageFamilyName / AppId
        Required when -WithProcMon is set.
 
    .PARAMETER DurationSeconds
        How long to capture for. Default 30s.
 
    .EXAMPLE
        Invoke-MsixInvestigation -PackagePath app.msix
 
    .EXAMPLE
        Invoke-MsixInvestigation -PackagePath app.msix -WithProcMon `
            -PackageFamilyName 'Contoso.App_8wekyb3d8bbwe' -AppId 'App'
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$PackagePath,
        [switch]$WithProcMon,
        [string]$PackageFamilyName,
        [string]$AppId,
        [int]$DurationSeconds = 30,
        [string]$ProcessName,
        # Path to a saved DebugView log (or any text file containing
        # PSF TraceFixup OutputDebugString lines).
        [string]$TraceLogPath
    )

    $pml = $null
    if ($WithProcMon) {
        if (-not $PackageFamilyName -or -not $AppId) {
            throw '-WithProcMon requires -PackageFamilyName and -AppId.'
        }
        $pml = Invoke-MsixProcMonCapture -PackageFamilyName $PackageFamilyName `
                                         -AppId $AppId `
                                         -DurationSeconds $DurationSeconds `
                                         -ProcessName $ProcessName
    }

    return Get-MsixCompatibilityReport -PackagePath $PackagePath `
                                       -PmlPath $pml `
                                       -TraceLogPath $TraceLogPath `
                                       -ProcessName $ProcessName
}


# Backward-compatible plural aliases
Set-Alias Get-MsixProcMonFailures Get-MsixProcMonFailure