Collect-RemediationLogs.ps1

<#PSScriptInfo
 
.VERSION 2.0.0
 
.GUID 4db7d295-a70a-4313-aeeb-909de8d2880b
 
.AUTHOR Jeff Gilbert
 
.RELEASENOTES
    10.13.26 v1.0.3 Initial release.
    10.23.26 v2.0.0 Added -System switch to download PsExec and enable cached log collection with required SYSTEM privileges.
#>


<#
.SYNOPSIS
    Collects Microsoft Intune Management Extension and Remediation
    troubleshooting information from a Windows endpoint.
     
.DESCRIPTION
    Collects:
      - Remediation-related Intune Management Extension logs
      - Custom Intune Remediation logs [$customLogPath]
      - Custom registry checkpoint paths [$CheckpointPath]
      - IME service information
      - HealthScripts cache inventory
      - Relevant registry information
      - DeviceManagement Enterprise Diagnostics event log
      - Device and enrollment summary
      - Recent IME log entries containing remediation-related terms
 
    Run from an elevated PowerShell session. Creates a timestamped ZIP file
    in .\CollectedRemediationLogs relative to the current directory by default.
 
    Use -System to download PsExec, verify its Microsoft signature, and
    relaunch this script interactively as SYSTEM. The relaunch preserves
    OutputPath, DaysOfEvents, CustomLogPath, and CheckpointPath and automatically
    enables IncludeCachedScripts.
    If already running as SYSTEM, no download or relaunch occurs; specify
    -IncludeCachedScripts explicitly to include cached script contents.
 
    Without -System, cached script contents are excluded. Specifying
    -IncludeCachedScripts without -System causes the script to stop.
 
.PARAMETER OutputPath
    Directory where the final ZIP file will be created. Defaults to
    .\CollectedRemediationLogs relative to the current directory.
    The directory is created if it does not exist. Relative paths are
    resolved before relaunching as SYSTEM.
 
.PARAMETER DaysOfEvents
    Number of days of DeviceManagement Enterprise Diagnostics events
    to include in the CSV export. Defaults to 3; valid values are 1 through 90.
    The native EVTX export is not limited by this setting.
 
.PARAMETER CustomLogPath
    Directory containing custom remediation logs. Defaults to C:\Logs\IR.
    Relative paths are resolved before relaunching as SYSTEM.
 
.PARAMETER CheckpointPath
    PowerShell registry path containing custom remediation checkpoints.
    Defaults to HKLM:\SOFTWARE\Remediations. Specify your company's
    actual registry path, such as HKLM:\SOFTWARE\Contoso\Remediations.
 
.PARAMETER IncludeCachedScripts
    Copies the HealthScripts cache, including DetectionScript.ps1 and
    RemediationScript.ps1 files. Requires -System, even when the current
    process already runs as SYSTEM. Automatically enabled when -System
    relaunches the script from a non-SYSTEM session.
    Use carefully because cached files can contain sensitive information
    and internal implementation details.
 
.PARAMETER System
    When not already running as SYSTEM, downloads PsExec.zip from
    https://live.sysinternals.com/Files/PsExec.zip to a temporary directory,
    verifies the Microsoft signature on PsExec64.exe, and relaunches this
    script with PsExec -accepteula -s -i using Windows PowerShell.
    Requires an elevated PowerShell session; does not request elevation.
    Accepts the Sysinternals PsExec license agreement and automatically
    enables cached-script collection during relaunch. Waits for collection
    to finish, then removes the temporary PsExec directory.
    If already running as SYSTEM, continues collection without relaunching.
 
.EXAMPLE
    .\Collect-RemediationLogs.ps1
 
    Collects diagnostics with three days of events in the CSV export and
    writes the ZIP to .\CollectedRemediationLogs. Excludes cached script contents.
 
.EXAMPLE
    .\Collect-RemediationLogs.ps1 -OutputPath "C:\Support" -DaysOfEvents 7
 
    Writes the ZIP to C:\Support and includes seven days of events in the CSV.
 
.EXAMPLE
    .\Collect-RemediationLogs.ps1 -System
 
    From an elevated non-SYSTEM session, relaunches as SYSTEM and automatically
    includes cached script contents.
 
.EXAMPLE
    .\Collect-RemediationLogs.ps1 -System -IncludeCachedScripts
 
    Explicitly includes cached script contents, including when the calling
    process is already running as SYSTEM.
 
.EXAMPLE
    .\Collect-RemediationLogs.ps1 -System -CustomLogPath "C:\Logs\Contoso" -CheckpointPath "HKLM:\SOFTWARE\Contoso\Remediations"
 
    Collects custom logs and registry checkpoints from the specified paths.
 
.NOTES
    Requires Windows. The SYSTEM relaunch requires internet access to
    live.sysinternals.com and permission to run PsExec and create its service.
    Review the resulting ZIP before sharing it because logs, registry exports,
    and optional cached scripts can contain sensitive information.
 
#>


[CmdletBinding()]
param (
    [Parameter()]
    [ValidateNotNullOrEmpty()]
    #[string]$OutputPath = "$env:windir\Temp",
    [string]$OutputPath = ".\CollectedRemediationLogs",

    [Parameter()]
    [ValidateRange(1, 90)]
    [int]$DaysOfEvents = 3,

    [Parameter()]
    [switch]$IncludeCachedScripts,

    [Parameter()]
    [switch]$System,

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$CustomLogPath = 'C:\Logs\IR',

    [Parameter()]
    [ValidateNotNullOrEmpty()]
    [string]$CheckpointPath = 'HKLM:\SOFTWARE\Remediations'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$currentIdentity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [System.Security.Principal.WindowsPrincipal]::new($currentIdentity)

if (-not $currentIdentity.IsSystem -and -not $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) {
    throw 'This script requires an elevated PowerShell session. Run PowerShell as administrator and try again.'
}

if ($IncludeCachedScripts -and -not $System) {
        throw 'The -IncludeCachedScripts switch requires the -System switch.'
    }

if ($System -and -not $currentIdentity.IsSystem) {
    $IncludeCachedScripts = $true

    Write-Host 'Relaunching script as SYSTEM...'
    $psExecDirectory = Join-Path $env:TEMP ('IntuneRemediation-PsExec-' + [guid]::NewGuid())

    try {
        New-Item -Path $psExecDirectory -ItemType Directory | Out-Null
        $psExecArchive = Join-Path $psExecDirectory 'PsExec.zip'

        Write-Host 'Downloading PsExec to relaunch log collection as SYSTEM.'
        Invoke-WebRequest `
            -Uri 'https://live.sysinternals.com/Files/PsExec.zip' `
            -OutFile $psExecArchive `
            -UseBasicParsing

        Expand-Archive -LiteralPath $psExecArchive -DestinationPath $psExecDirectory
        $psExecPath = Join-Path $psExecDirectory 'PsExec64.exe'
        $signature = Get-AuthenticodeSignature -LiteralPath $psExecPath

        if ($signature.Status -ne 'Valid' -or
            $signature.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation(?:,|$)') {
            throw 'PsExec64.exe does not have a valid Microsoft signature.'
        }

        $psExecArguments = @(
            '-accepteula', '-s', '-i',
            'powershell.exe', '-NoProfile', '-File', $PSCommandPath,
            '-System',
            '-OutputPath', $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($OutputPath),
            '-DaysOfEvents', $DaysOfEvents.ToString(),
            '-CustomLogPath', $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CustomLogPath),
            '-CheckpointPath', $CheckpointPath
        )

        if ($IncludeCachedScripts) {
            $psExecArguments += '-IncludeCachedScripts'
        }

        & $psExecPath @psExecArguments

        if ($LASTEXITCODE -ne 0) {
            throw "PsExec or the SYSTEM collection process returned exit code $LASTEXITCODE."
        }
    }
    finally {
        if (Test-Path -LiteralPath $psExecDirectory) {
            Remove-Item -LiteralPath $psExecDirectory -Recurse -Force -ErrorAction SilentlyContinue
        }
    }

    return
}

$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$computerName = $env:COMPUTERNAME

$collectionName = "IntuneRemediationLogs_${computerName}_${timestamp}"
$workingPath = Join-Path -Path $env:TEMP -ChildPath $collectionName
$zipPath = Join-Path -Path $OutputPath -ChildPath "$collectionName.zip"

$imeLogPath = 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs'
$healthScriptsPath = 'C:\Windows\IMECache\HealthScripts'

$remediationImeLogPattern =
    '^(HealthScripts|AgentExecutor|IntuneManagementExtension)(-\d{8}-\d{6})?\.log$'

$imeRegistryPath =
    'HKLM:\SOFTWARE\Microsoft\IntuneManagementExtension'

$sideCarRegistryPath =
    'HKLM:\SOFTWARE\Microsoft\IntuneManagementExtension\SideCarPolicies\Scripts'

$eventLogName =
    'Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin'

function Write-CollectionLog {
    param (
        [Parameter(Mandatory)]
        [string]$Message,

        [Parameter()]
        [ValidateSet('INFO', 'WARN', 'ERROR')]
        [string]$Level = 'INFO'
    )

    $entry = '[{0}] [{1}] {2}' -f `
        (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'),
        $Level,
        $Message

    Write-Host $entry

    if (Test-Path -LiteralPath $workingPath) {
        $entry | Out-File `
            -FilePath (Join-Path $workingPath 'Collection.log') `
            -Append `
            -Encoding utf8
    }
}

function Export-RegistryTree {
    param (
        [Parameter(Mandatory)]
        [string]$PowerShellPath,

        [Parameter(Mandatory)]
        [string]$RegExePath,

        [Parameter(Mandatory)]
        [string]$OutputFile
    )

    if (-not (Test-Path -LiteralPath $PowerShellPath)) {
        Write-CollectionLog `
            -Message "Registry path not present: $PowerShellPath" `
            -Level WARN

        return
    }

    try {
        & reg.exe export $RegExePath $OutputFile /y | Out-Null

        if ($LASTEXITCODE -ne 0) {
            throw "reg.exe returned exit code $LASTEXITCODE."
        }

        Write-CollectionLog "Exported registry path: $RegExePath"
    }
    catch {
        Write-CollectionLog `
            -Message "Could not export $RegExePath. $($_.Exception.Message)" `
            -Level ERROR
    }
}

try {
    if (-not (Test-Path -LiteralPath $OutputPath)) {
        New-Item `
            -Path $OutputPath `
            -ItemType Directory `
            -Force |
            Out-Null
    }

    if (Test-Path -LiteralPath $workingPath) {
        Remove-Item `
            -LiteralPath $workingPath `
            -Recurse `
            -Force
    }

    New-Item `
        -Path $workingPath `
        -ItemType Directory `
        -Force |
        Out-Null

    Write-CollectionLog "Starting Intune Remediation log collection."

    # ------------------------------------------------------------
    # Collection directories
    # ------------------------------------------------------------

    $imeDestination =
        Join-Path $workingPath 'IntuneManagementExtension-Logs'

    $customDestination =
        Join-Path $workingPath 'Custom-Remediation-Logs'

    $registryDestination =
        Join-Path $workingPath 'Registry'

    $eventDestination =
        Join-Path $workingPath 'EventLogs'

    $cacheDestination =
        Join-Path $workingPath 'HealthScripts-Cache'

    foreach ($directory in @(
        $imeDestination,
        $customDestination,
        $registryDestination,
        $eventDestination,
        $cacheDestination
    )) {
        New-Item `
            -Path $directory `
            -ItemType Directory `
            -Force |
            Out-Null
    }

    # ------------------------------------------------------------
    # IME logs
    # ------------------------------------------------------------

    $remediationImeLogs = @()

    if (Test-Path -LiteralPath $imeLogPath) {
        $remediationImeLogs = @(
            Get-ChildItem `
            -LiteralPath $imeLogPath `
            -File `
            -ErrorAction Stop |
            Where-Object Name -Match $remediationImeLogPattern
        )

        if ($remediationImeLogs.Count -gt 0) {
            $remediationImeLogs |
                Copy-Item `
                -Destination $imeDestination `
                -Force `
                -ErrorAction Stop

            Write-CollectionLog `
                "Collected $($remediationImeLogs.Count) remediation-related IME logs."
        }
        else {
            Write-CollectionLog `
                -Message "No remediation-related IME logs were found in: $imeLogPath" `
                -Level WARN
        }
    }
    else {
        Write-CollectionLog `
            -Message "IME log folder was not found: $imeLogPath" `
            -Level WARN
    }

    # ------------------------------------------------------------
    # Custom remediation logs
    # ------------------------------------------------------------

    if (Test-Path -LiteralPath $customLogPath) {
        Copy-Item `
            -LiteralPath $customLogPath `
            -Destination $customDestination `
            -Recurse `
            -Force `
            -ErrorAction Continue

        Write-CollectionLog "Collected custom remediation logs."
    }
    else {
        Write-CollectionLog `
            -Message "Custom remediation log folder was not found." `
            -Level WARN
    }

    # ------------------------------------------------------------
    # IME service information
    # ------------------------------------------------------------

    $serviceOutput =
        Join-Path $workingPath 'IntuneManagementExtension-Service.txt'

    try {
        $imeService = Get-CimInstance `
            -ClassName Win32_Service `
            -Filter "Name='IntuneManagementExtension'"

        if ($null -ne $imeService) {
            $imeService |
                Select-Object `
                    Name,
                    DisplayName,
                    State,
                    StartMode,
                    StartName,
                    ProcessId,
                    PathName |
                Format-List |
                Out-File `
                    -FilePath $serviceOutput `
                    -Encoding utf8
        }
        else {
            'The IntuneManagementExtension service was not found.' |
                Out-File `
                    -FilePath $serviceOutput `
                    -Encoding utf8
        }
    }
    catch {
        $_ |
            Out-String |
            Out-File `
                -FilePath $serviceOutput `
                -Encoding utf8
    }

    # ------------------------------------------------------------
    # Device summary
    # ------------------------------------------------------------

    $deviceOutput =
        Join-Path $workingPath 'Device-Summary.txt'

    $operatingSystem = Get-CimInstance Win32_OperatingSystem
    $computerSystem = Get-CimInstance Win32_ComputerSystem
    $bios = Get-CimInstance Win32_BIOS

    [pscustomobject]@{
        ComputerName        = $computerName
        Manufacturer        = $computerSystem.Manufacturer
        Model               = $computerSystem.Model
        SerialNumber        = $bios.SerialNumber
        OperatingSystem     = $operatingSystem.Caption
        OSVersion           = $operatingSystem.Version
        OSBuild             = $operatingSystem.BuildNumber
        LastBootTime        = $operatingSystem.LastBootUpTime
        PowerShellVersion   = $PSVersionTable.PSVersion.ToString()
        CollectionTime      = Get-Date
        CollectionUser      = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
        Is64BitOperatingSystem = [System.Environment]::Is64BitOperatingSystem
        Is64BitProcess      = [System.Environment]::Is64BitProcess
    } |
        Format-List |
        Out-File `
            -FilePath $deviceOutput `
            -Encoding utf8

    # ------------------------------------------------------------
    # Scheduled tasks related to Enterprise Management
    # ------------------------------------------------------------

    $taskOutput =
        Join-Path $workingPath 'EnterpriseManagement-ScheduledTasks.txt'

    try {
        Get-ScheduledTask -ErrorAction Stop |
            Where-Object {
                $_.TaskPath -like '\Microsoft\Windows\EnterpriseMgmt*' -or
                $_.TaskName -match 'Intune|Enterprise'
            } |
            Select-Object `
                TaskPath,
                TaskName,
                State,
                Description |
            Sort-Object TaskPath, TaskName |
            Format-Table -AutoSize |
            Out-String -Width 300 |
            Out-File `
                -FilePath $taskOutput `
                -Encoding utf8
    }
    catch {
        $_ |
            Out-String |
            Out-File `
                -FilePath $taskOutput `
                -Encoding utf8
    }

    # ------------------------------------------------------------
    # HealthScripts cache inventory
    # ------------------------------------------------------------

    $inventoryCsv =
        Join-Path $cacheDestination 'HealthScripts-Inventory.csv'

    if (Test-Path -LiteralPath $healthScriptsPath) {
        $scriptFiles = Get-ChildItem `
            -LiteralPath $healthScriptsPath `
            -File `
            -Recurse `
            -ErrorAction Continue

        $inventory = foreach ($file in $scriptFiles) {
            [pscustomobject]@{
                RelativePath = $file.FullName.Substring(
                    $healthScriptsPath.Length
                ).TrimStart('\')

                FileName      = $file.Name
                Length        = $file.Length
                LastWriteTime = $file.LastWriteTime
            }
        }

        $inventory |
            Export-Csv `
                -LiteralPath $inventoryCsv `
                -NoTypeInformation `
                -Encoding utf8

        if ($IncludeCachedScripts) {
            Copy-Item `
                -LiteralPath $healthScriptsPath `
                -Destination $cacheDestination `
                -Recurse `
                -Force `
                -ErrorAction Continue

            Write-CollectionLog `
                "Collected HealthScripts inventory and cached script files."
        }
        else {
            Write-CollectionLog `
                "Collected HealthScripts inventory. Script contents were excluded. Use -System to pull cached script contents."
        }
    }
    else {
        Write-CollectionLog `
            -Message "HealthScripts cache was not found: $healthScriptsPath" `
            -Level WARN
    }

    # ------------------------------------------------------------
    # Registry exports
    # ------------------------------------------------------------

    Export-RegistryTree `
        -PowerShellPath $imeRegistryPath `
        -RegExePath 'HKLM\SOFTWARE\Microsoft\IntuneManagementExtension' `
        -OutputFile (
            Join-Path $registryDestination `
                'IntuneManagementExtension.reg'
        )

    Export-RegistryTree `
        -PowerShellPath $sideCarRegistryPath `
        -RegExePath `
            'HKLM\SOFTWARE\Microsoft\IntuneManagementExtension\SideCarPolicies\Scripts' `
        -OutputFile (
            Join-Path $registryDestination `
                'SideCarPolicies-Scripts.reg'
        )

    Export-RegistryTree `
        -PowerShellPath $checkpointPath `
        -RegExePath $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($CheckpointPath) `
        -OutputFile (
            Join-Path $registryDestination `
                'Company-IntuneRemediations.reg'
        )

    # ------------------------------------------------------------
    # DeviceManagement Enterprise Diagnostics event log
    # ------------------------------------------------------------

    $startTime = (Get-Date).AddDays(-$DaysOfEvents)

    try {
        $events = Get-WinEvent `
            -FilterHashtable @{
                LogName   = $eventLogName
                StartTime = $startTime
            } `
            -ErrorAction Stop

        $events |
            Select-Object `
                TimeCreated,
                Id,
                LevelDisplayName,
                ProviderName,
                Message |
            Export-Csv `
                -LiteralPath (
                    Join-Path $eventDestination `
                        'DeviceManagement-Enterprise-Diagnostics.csv'
                ) `
                -NoTypeInformation `
                -Encoding utf8

        Write-CollectionLog `
            "Collected $($events.Count) DeviceManagement events."
    }
    catch {
        Write-CollectionLog `
            -Message "Could not collect DeviceManagement events. $($_.Exception.Message)" `
            -Level WARN
    }

    # Export the native event log when possible.
    try {
        $evtxOutput =
            Join-Path $eventDestination `
                'DeviceManagement-Enterprise-Diagnostics.evtx'

        & wevtutil.exe epl $eventLogName $evtxOutput

        if ($LASTEXITCODE -ne 0) {
            throw "wevtutil.exe returned exit code $LASTEXITCODE."
        }
    }
    catch {
        Write-CollectionLog `
            -Message "Could not export the EVTX file. $($_.Exception.Message)" `
            -Level WARN
    }

    # ------------------------------------------------------------
    # Remediation-focused log extracts
    # ------------------------------------------------------------

    $extractOutput =
        Join-Path $workingPath 'Remediation-Relevant-Log-Entries.txt'

    $searchTerms = @(
        'HealthScripts',
        'Remediation',
        'DetectionScript',
        'RemediationScript',
        'exit code',
        'SideCarPolicies',
        'error',
        'exception',
        'failed'
    )

    foreach ($logFile in $remediationImeLogs) {
        "============================================================" |
            Out-File `
                -FilePath $extractOutput `
                -Append `
                -Encoding utf8

        "SOURCE: $($logFile.FullName)" |
            Out-File `
                -FilePath $extractOutput `
                -Append `
                -Encoding utf8

        "============================================================" |
            Out-File `
                -FilePath $extractOutput `
                -Append `
                -Encoding utf8

        Select-String `
            -LiteralPath $logFile.FullName `
            -Pattern $searchTerms `
            -SimpleMatch `
            -ErrorAction Continue |
            Select-Object `
                Path,
                LineNumber,
                Line |
            Format-Table -Wrap |
            Out-String -Width 400 |
            Out-File `
                -FilePath $extractOutput `
                -Append `
                -Encoding utf8
    }

    # ------------------------------------------------------------
    # Package manifest
    # ------------------------------------------------------------

    Get-ChildItem `
        -LiteralPath $workingPath `
        -File `
        -Recurse |
        Select-Object `
            FullName,
            Length,
            LastWriteTime |
        Export-Csv `
            -LiteralPath (
                Join-Path $workingPath 'Collection-Manifest.csv'
            ) `
            -NoTypeInformation `
            -Encoding utf8

    # ------------------------------------------------------------
    # Compress the package
    # ------------------------------------------------------------

    if (Test-Path -LiteralPath $zipPath) {
        Remove-Item `
            -LiteralPath $zipPath `
            -Force
    }

    Compress-Archive `
        -Path (Join-Path $workingPath '*') `
        -DestinationPath $zipPath `
        -CompressionLevel Optimal `
        -Force

    Write-CollectionLog "Collection completed successfully."
    Write-Host
    Write-Host "Output package: $zipPath" -ForegroundColor Green

    Get-Item -LiteralPath $zipPath |
        Select-Object FullName, Length, LastWriteTime
    
    # ------------------------------------------------------------
    # Grab the help file
    # ------------------------------------------------------------
    
    $Uri = "https://raw.githubusercontent.com/jeffgilb/Intune-Remediation-Framework/main/Troubleshooting/troubleshooting.md"
    $OutFile = "$OutputPath\troubleshooting.md"
    Invoke-WebRequest -Uri $Uri -OutFile $OutFile
    Write-Host "Troubleshooting help file downloaded to $OutFile" -ForegroundColor Green

    if ($system -eq $true) {
        Write-Host
        pause # Pause to display output for SYSTEM mode before the console closes.

    }
}
catch {
    Write-CollectionLog `
        -Message "Collection failed. $($_.Exception.Message)" `
        -Level ERROR

    throw
}
finally {
    if (Test-Path -LiteralPath $workingPath) {
        Remove-Item `
            -LiteralPath $workingPath `
            -Recurse `
            -Force `
            -ErrorAction SilentlyContinue
    }
}