Private/Invoke-WUSearch.ps1
|
function Invoke-WUSearch { <# .SYNOPSIS Runs a Windows Update Agent search in an isolated process with a hard timeout. .DESCRIPTION The Windows Update Agent Search method is synchronous and can block indefinitely. This helper performs the COM operation in a child PowerShell process, normalizes the COM results into serializable objects, and terminates the child if the timeout is exceeded. Search results remain policy-scoped. On devices managed by Intune, Windows Update for Business, or Windows Autopatch, this function cannot bypass deployment approvals, rollout timing, safeguard holds, or the configured scan source. #> [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$Criteria, [ValidateRange(5, 1800)] [int]$TimeoutSeconds = 120, [ValidateRange(0, 2000)] [int]$HistoryLimit = 200, [string]$LogPath ) $outputPath = Join-Path ([System.IO.Path]::GetTempPath()) ( 'WindowsUpdateTools-WUSearch-{0}.clixml' -f ([guid]::NewGuid().ToString('N')) ) $payload = @{ Criteria = $Criteria HistoryLimit = $HistoryLimit OutputPath = $outputPath } | ConvertTo-Json -Compress $payloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($payload)) $childScript = @' $ErrorActionPreference = 'Stop' $payloadJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('@@PAYLOAD@@')) $payload = $payloadJson | ConvertFrom-Json $envelope = [ordered]@{ Success = $false Error = $null ErrorHResult = $null Updates = @() History = @() } try { $updateSession = New-Object -ComObject Microsoft.Update.Session $updateSearcher = $updateSession.CreateUpdateSearcher() if ([int]$payload.HistoryLimit -gt 0) { $historyCount = $updateSearcher.GetTotalHistoryCount() if ($historyCount -gt 0) { $history = $updateSearcher.QueryHistory(0, [Math]::Min($historyCount, [int]$payload.HistoryLimit)) $historyItems = foreach ($entry in $history) { $kbNumber = $null if ($entry.Title -match 'KB(\d+)') { $kbNumber = "KB$($matches[1])" } [PSCustomObject]@{ Title = $entry.Title KBNumber = $kbNumber Date = $entry.Date ResultCode = $entry.ResultCode HResult = $entry.HResult Operation = $entry.Operation } } $envelope.History = @($historyItems) } } $searchResult = $updateSearcher.Search([string]$payload.Criteria) $updateItems = foreach ($update in $searchResult.Updates) { $categories = @() foreach ($category in $update.Categories) { $categories += $category.Name } $kbArticleIds = @() foreach ($kbArticleId in $update.KBArticleIDs) { $kbArticleIds += $kbArticleId } $securityBulletinIds = @() foreach ($securityBulletinId in $update.SecurityBulletinIDs) { $securityBulletinIds += $securityBulletinId } $actualDownloadSize = [int64]0 $downloadContentsCount = 0 try { if ($update.DownloadContents) { $downloadContentsCount = $update.DownloadContents.Count foreach ($content in $update.DownloadContents) { if ($content.Size -gt 0) { $actualDownloadSize += [int64]$content.Size } } } } catch { $actualDownloadSize = [int64]0 $downloadContentsCount = 0 } $rebootRequired = $false if ($update.InstallationBehavior) { $rebootRequired = $update.InstallationBehavior.RebootBehavior -ne 0 } $updateType = if ($categories -match 'Driver') { 'Driver' } else { 'Software' } [PSCustomObject]@{ Title = $update.Title Description = $update.Description Categories = @($categories) Type = $updateType KBArticleIDs = @($kbArticleIds) SecurityBulletinIDs = @($securityBulletinIds) MsrcSeverity = $update.MsrcSeverity AutoSelectOnWebSites = $update.AutoSelectOnWebSites MaxDownloadSize = [int64]$update.MaxDownloadSize ActualDownloadSize = $actualDownloadSize DownloadContentsCount = $downloadContentsCount IsDownloaded = [bool]$update.IsDownloaded RebootRequired = [bool]$rebootRequired IsHidden = [bool]$update.IsHidden IsMandatory = [bool]$update.IsMandatory ReleaseDate = $update.LastDeploymentChangeTime SupportUrl = $update.SupportUrl UpdateID = $update.Identity.UpdateID RevisionNumber = $update.Identity.RevisionNumber } } $envelope.Updates = @($updateItems) $envelope.Success = $true } catch { $envelope.Error = $_.Exception.Message $envelope.ErrorHResult = $_.Exception.HResult } [PSCustomObject]$envelope | Export-Clixml -LiteralPath ([string]$payload.OutputPath) -Force '@.Replace('@@PAYLOAD@@', $payloadBase64) $encodedCommand = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($childScript)) $hostPath = Join-Path $PSHOME 'powershell.exe' if (-not (Test-Path -LiteralPath $hostPath)) { $hostPath = Join-Path $PSHOME 'pwsh.exe' } if (-not (Test-Path -LiteralPath $hostPath)) { throw "Could not locate a PowerShell host under '$PSHOME'." } $process = $null try { Write-WULog -Message "Starting policy-scoped Windows Update Agent search (timeout: $TimeoutSeconds seconds)" -LogPath $LogPath $process = Start-Process -FilePath $hostPath ` -ArgumentList @('-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', $encodedCommand) ` -WindowStyle Hidden ` -PassThru ` -ErrorAction Stop if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { try { $process.Kill() $process.WaitForExit() } catch { Write-WULog -Message "Could not terminate timed-out WUA search process: $($_.Exception.Message)" -Level Warning -LogPath $LogPath } throw [TimeoutException]::new( "Windows Update Agent search exceeded the $TimeoutSeconds-second timeout. The isolated search process was terminated." ) } if (-not (Test-Path -LiteralPath $outputPath)) { throw "Windows Update Agent search process exited with code $($process.ExitCode) without returning a result." } $result = Import-Clixml -LiteralPath $outputPath if (-not $result.Success) { $errorSuffix = if ($null -ne $result.ErrorHResult) { ' (HRESULT 0x{0:X8})' -f ([uint32]$result.ErrorHResult) } else { '' } throw "Windows Update Agent search failed: $($result.Error)$errorSuffix" } return $result } finally { if ($process -and -not $process.HasExited) { try { $process.Kill() $process.WaitForExit() } catch { Write-WULog -Message "Could not terminate WUA search child process during cleanup: $($_.Exception.Message)" -Level Warning -LogPath $LogPath } } Remove-Item -LiteralPath $outputPath -Force -ErrorAction SilentlyContinue } } |