Scripts/VM/Windows/update-status.ps1

# update-status: the full patch picture for this machine in one shot -
# * when it last installed updates (history, from the Windows Update Agent)
# * how many SECURITY vs OTHER updates are pending NOW
# * whether a reboot is pending
#
# The pending search ($searcher.Search) queries Windows Update / WSUS online, so it can
# take a while on a cold agent - that's the same assessment Update Manager performs, so
# the counts line up. Emits a single JSON object on stdout. Continue (not Stop) so an
# individual probe failure degrades to nulls rather than yielding no row.
$ErrorActionPreference = 'Continue'

$nowUtc       = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
$last         = $null      # DateTime of newest successful install
$lastTitle    = $null
$count30d     = $null
$secPending   = $null
$otherPending = $null
$reboot       = $false
$assessed     = $false
$source       = $null

# The Windows Update Agent returns its Date as UTC wall-clock but Kind=Unspecified;
# calling ToUniversalTime() on that would wrongly shift it. Treat Unspecified as UTC.
function ConvertTo-Utc($dt) {
    if ($null -eq $dt) { return $null }
    if ($dt.Kind -eq [DateTimeKind]::Local) { return $dt.ToUniversalTime() }
    return [DateTime]::SpecifyKind($dt, [DateTimeKind]::Utc)
}

try {
    $session  = New-Object -ComObject Microsoft.Update.Session
    $searcher = $session.CreateUpdateSearcher()
    $source   = 'WindowsUpdateAgent'

    # ---- history: last successful install (captures cumulative updates Get-HotFix misses) ----
    $total = $searcher.GetTotalHistoryCount()
    if ($total -gt 0) {
        $hist = @($searcher.QueryHistory(0, $total) |
            Where-Object { $_.Operation -eq 1 -and $_.ResultCode -eq 2 })   # installed & succeeded
        if ($hist.Count -gt 0) {
            $latest    = $hist | Sort-Object Date -Descending | Select-Object -First 1
            $last      = $latest.Date
            $lastTitle = $latest.Title
            $cut       = [DateTime]::UtcNow.AddDays(-30)
            $count30d  = @($hist | Where-Object { $_.Date -ge $cut }).Count
        }
    }

    # ---- pending: ask WU/WSUS what's not installed, split security vs other ----
    $result   = $searcher.Search("IsInstalled=0 and Type='Software'")
    $pending  = @($result.Updates)
    $assessed = $true
    $isSecurity = {
        param($u)
        if ($u.MsrcSeverity) { return $true }                              # security updates carry a severity
        foreach ($c in $u.Categories) { if ($c.Name -eq 'Security Updates') { return $true } }
        return $false
    }
    $sec          = @($pending | Where-Object { & $isSecurity $_ })
    $secPending   = $sec.Count
    $otherPending = $pending.Count - $sec.Count
}
catch { }

# Reboot pending (from the WUA system info).
try { $reboot = [bool](New-Object -ComObject Microsoft.Update.SystemInfo).RebootRequired } catch { }

# Fallback for last install if WUA history was unavailable.
if (-not $last) {
    $hf = Get-HotFix -ErrorAction SilentlyContinue |
        Where-Object InstalledOn | Sort-Object InstalledOn -Descending | Select-Object -First 1
    if ($hf) {
        if (-not $source) { $source = 'Get-HotFix' }
        $last      = $hf.InstalledOn
        $lastTitle = $hf.HotFixID
    }
}

$lastUtc = ConvertTo-Utc $last
$lastIso = if ($lastUtc) { $lastUtc.ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null }
$days    = if ($lastUtc) { [int]([DateTime]::UtcNow - $lastUtc).TotalDays } else { $null }

[pscustomobject]@{
    NowUtc            = $nowUtc
    LastUpdate        = $lastIso
    LastUpdatePackage = $lastTitle
    DaysSinceUpdate   = $days
    UpdateCount30d    = $count30d
    SecurityPending   = $secPending
    OtherPending      = $otherPending
    RebootRequired    = $reboot
    AssessedFresh     = $assessed
    Source            = $source
} | ConvertTo-Json -Compress