Sponsored by

THE PROBLEM

Patching reports lie to you, and they lie in a very specific way.

The update installs. The compliance dashboard goes green. The report you send to management says 98%. And the server sits there with the fix staged in a registry key, not actually applied, because it's waiting for a reboot that nobody scheduled.

The gap between "patched" and "protected" is a reboot, and in most environments that gap is measured in weeks. Somebody applies updates during the maintenance window, the reboot step fails or gets skipped on three machines, and those three machines quietly stay vulnerable while every report insists they're fine.

The operational version is just as common. A server has been up for 400 days. Nobody wants to touch it because nobody's confident it comes back. So the pending reboot flag sits there for a year, accumulating staged updates, and the eventual reboot — when it finally happens, usually unplanned — takes 45 minutes and three failed services because it's applying a year of deferred changes at once.

This week we build the report that shows you exactly which servers are in limbo, why, and how long they've been there.

THE SCRIPT

Save the script below to:

C:\Scripts\PendingRebootReport.ps1

powershell

# Pending Reboot & Uptime Reporter
# Automate & Operate — automateandoperate.com

# Email settings — update these
$from = "[email protected]"
$to = "[email protected]"
$smtpServer = "your.smtp.server"
$smtpPort = 587
$username = "[email protected]"
$password = ConvertTo-SecureString "yourpassword" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)

$logFile = "C:\Logs\PendingRebootReport.log"
$reportFile = "C:\Logs\PendingRebootReport.csv"

# Servers to check.
# Leave empty to auto-discover enabled Windows servers from AD.
$servers = @()

# Flag servers with uptime beyond this many days
$uptimeWarningDays = 60
$uptimeCriticalDays = 120

# Create log folder if missing
if (-not (Test-Path "C:\Logs")) {
    New-Item -ItemType Directory -Path "C:\Logs" -Force | Out-Null
}

$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"

$report = @()
$pendingReboot = @()
$longUptime = @()
$unreachable = @()

# ---------- DISCOVER SERVERS ----------
if ($servers.Count -eq 0) {
    try {
        Import-Module ActiveDirectory -ErrorAction Stop
        $servers = (Get-ADComputer -Filter {
            Enabled -eq $true -and OperatingSystem -like "*Server*"
        } -Properties OperatingSystem).Name | Sort-Object

        Add-Content -Path $logFile -Value "$timestamp — Auto-discovered $($servers.Count) server(s) from AD"
    } catch {
        Add-Content -Path $logFile -Value "$timestamp — AD discovery failed: $_ — falling back to localhost"
        $servers = @("localhost")
    }
}

foreach ($server in $servers) {

    try {
        $result = Invoke-Command -ComputerName $server -ScriptBlock {

            $reasons = @()

            # --- Component Based Servicing (Windows Updates) ---
            if (Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending") {
                $reasons += "Component Based Servicing"
            }

            # --- Windows Update auto-update ---
            if (Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired") {
                $reasons += "Windows Update"
            }

            # --- Pending file rename operations ---
            try {
                $pfro = Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" `
                    -Name PendingFileRenameOperations -ErrorAction SilentlyContinue
                if ($pfro.PendingFileRenameOperations) {
                    $reasons += "Pending File Rename ($($pfro.PendingFileRenameOperations.Count) file(s))"
                }
            } catch { }

            # --- Pending computer rename / domain join ---
            try {
                $activeName = (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName" `
                    -Name ComputerName -ErrorAction SilentlyContinue).ComputerName
                $pendingName = (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName" `
                    -Name ComputerName -ErrorAction SilentlyContinue).ComputerName
                if ($activeName -and $pendingName -and $activeName -ne $pendingName) {
                    $reasons += "Pending Computer Rename ($activeName -> $pendingName)"
                }
            } catch { }

            if (Test-Path "HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\JoinDomain") {
                $reasons += "Pending Domain Join"
            }
            if (Test-Path "HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\AvoidSpnSet") {
                $reasons += "Pending SPN Set"
            }

            # --- SCCM / Configuration Manager client ---
            try {
                $ccm = Invoke-WmiMethod -Namespace "root\ccm\ClientSDK" `
                    -Class CCM_ClientUtilities -Name DetermineIfRebootPending -ErrorAction SilentlyContinue
                if ($ccm -and ($ccm.RebootPending -or $ccm.IsHardRebootPending)) {
                    $reasons += "Configuration Manager client"
                }
            } catch { }

            # --- Uptime ---
            $os = Get-CimInstance -ClassName Win32_OperatingSystem
            $lastBoot = $os.LastBootUpTime
            $uptimeDays = [math]::Round((New-TimeSpan -Start $lastBoot -End (Get-Date)).TotalDays, 1)

            # --- Last installed hotfix, for context ---
            $lastHotfix = Get-HotFix | Sort-Object InstalledOn -Descending | Select-Object -First 1

            [PSCustomObject]@{
                PendingReboot = ($reasons.Count -gt 0)
                Reasons       = $reasons -join "; "
                LastBoot      = $lastBoot
                UptimeDays    = $uptimeDays
                OSName        = $os.Caption
                LastHotfixId  = $lastHotfix.HotFixID
                LastHotfixOn  = $lastHotfix.InstalledOn
            }

        } -ErrorAction Stop

        $uptimeStatus = if ($result.UptimeDays -ge $uptimeCriticalDays) {
            "CRITICAL"
        } elseif ($result.UptimeDays -ge $uptimeWarningDays) {
            "WARNING"
        } else {
            "OK"
        }

        # Days between last patch and now, while still not rebooted
        $daysSincePatch = if ($result.LastHotfixOn) {
            [math]::Round((New-TimeSpan -Start $result.LastHotfixOn -End (Get-Date)).TotalDays, 0)
        } else {
            "N/A"
        }

        $entry = [PSCustomObject]@{
            Server         = $server
            OS             = $result.OSName
            PendingReboot  = $result.PendingReboot
            Reasons        = if ($result.Reasons) { $result.Reasons } else { "None" }
            LastBoot       = $result.LastBoot.ToString("yyyy-MM-dd HH:mm")
            UptimeDays     = $result.UptimeDays
            UptimeStatus   = $uptimeStatus
            LastHotfix     = $result.LastHotfixId
            LastHotfixDate = if ($result.LastHotfixOn) { $result.LastHotfixOn.ToString("yyyy-MM-dd") } else { "UNKNOWN" }
            DaysSincePatch = $daysSincePatch
        }

        $report += $entry

        if ($result.PendingReboot) { $pendingReboot += $entry }
        if ($uptimeStatus -ne "OK") { $longUptime += $entry }

        Add-Content -Path $logFile -Value "$timestamp — $server — Pending: $($result.PendingReboot) — Uptime: $($result.UptimeDays)d — $($result.Reasons)"

    } catch {
        $entry = [PSCustomObject]@{
            Server         = $server
            OS             = "N/A"
            PendingReboot  = "UNKNOWN"
            Reasons        = "UNREACHABLE: $($_.Exception.Message)"
            LastBoot       = "N/A"
            UptimeDays     = "N/A"
            UptimeStatus   = "ERROR"
            LastHotfix     = "N/A"
            LastHotfixDate = "N/A"
            DaysSincePatch = "N/A"
        }

        $report += $entry
        $unreachable += $entry

        Add-Content -Path $logFile -Value "$timestamp — $server — ERROR: $_"
    }
}

# ---------- EXPORT ----------
$report | Sort-Object PendingReboot -Descending | Export-Csv -Path $reportFile -NoTypeInformation

# ---------- BUILD EMAIL ----------
$criticalUptime = $longUptime | Where-Object { $_.UptimeStatus -eq "CRITICAL" }
$warningUptime  = $longUptime | Where-Object { $_.UptimeStatus -eq "WARNING" }

# Servers both pending reboot AND patched a while ago — the real risk group
$stuckAndPatched = $pendingReboot | Where-Object {
    $_.DaysSincePatch -ne "N/A" -and [int]$_.DaysSincePatch -gt 7
}

$emailBody  = "Pending Reboot & Uptime Report — $timestamp`n"
$emailBody += "Servers checked: $($servers.Count)`n"
$emailBody += "Reachable: $($report.Count - $unreachable.Count)  |  Unreachable: $($unreachable.Count)`n`n"
$emailBody += "Pending reboot: $($pendingReboot.Count)`n"
$emailBody += "Patched over 7 days ago and still not rebooted: $($stuckAndPatched.Count)`n"
$emailBody += "Uptime over $uptimeCriticalDays days: $($criticalUptime.Count)`n"
$emailBody += "Uptime over $uptimeWarningDays days: $($warningUptime.Count)`n`n"

if ($stuckAndPatched.Count -gt 0) {
    $emailBody += "*** PATCHED BUT NOT PROTECTED — REBOOT THESE FIRST ***`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($s in ($stuckAndPatched | Sort-Object { [int]$_.DaysSincePatch } -Descending)) {
        $emailBody += "Server:       $($s.Server)`n"
        $emailBody += "Last hotfix:  $($s.LastHotfix) on $($s.LastHotfixDate) ($($s.DaysSincePatch) days ago)`n"
        $emailBody += "Uptime:       $($s.UptimeDays) days`n"
        $emailBody += "Reasons:      $($s.Reasons)`n"
        $emailBody += "-" * 40 + "`n"
    }
    $emailBody += "`n"
}

if ($pendingReboot.Count -gt 0) {
    $emailBody += "ALL SERVERS PENDING REBOOT:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($p in ($pendingReboot | Sort-Object UptimeDays -Descending)) {
        $emailBody += "$($p.Server) — up $($p.UptimeDays)d — $($p.Reasons)`n"
    }
    $emailBody += "`n"
}

if ($criticalUptime.Count -gt 0) {
    $emailBody += "UPTIME OVER $uptimeCriticalDays DAYS:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($c in ($criticalUptime | Sort-Object UptimeDays -Descending)) {
        $emailBody += "$($c.Server) — $($c.UptimeDays) days (last boot $($c.LastBoot))`n"
    }
    $emailBody += "`n"
}

if ($warningUptime.Count -gt 0) {
    $emailBody += "UPTIME OVER $uptimeWarningDays DAYS:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($w in ($warningUptime | Sort-Object UptimeDays -Descending)) {
        $emailBody += "$($w.Server) — $($w.UptimeDays) days`n"
    }
    $emailBody += "`n"
}

if ($unreachable.Count -gt 0) {
    $emailBody += "UNREACHABLE — CHECK THESE:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($u in $unreachable) {
        $emailBody += "$($u.Server) — $($u.Reasons)`n"
    }
    $emailBody += "`n"
}

if ($pendingReboot.Count -eq 0 -and $longUptime.Count -eq 0 -and $unreachable.Count -eq 0) {
    $emailBody += "No pending reboots, no excessive uptime, all servers reachable.`n`n"
}

$emailBody += "FULL INVENTORY:`n"
$emailBody += "=" * 60 + "`n"
foreach ($r in ($report | Sort-Object Server)) {
    $flag = if ($r.PendingReboot -eq $true) { "[REBOOT] " } else { "         " }
    $emailBody += "$flag$($r.Server) — up $($r.UptimeDays)d — last patch $($r.LastHotfixDate)`n"
}

$emailBody += "`nFull report: $reportFile`n"
$emailBody += "`nTo reboot a server remotely:`n"
$emailBody += "Restart-Computer -ComputerName SERVER -Force`n`n"
$emailBody += "Automate & Operate — automateandoperate.com"

$subject = if ($stuckAndPatched.Count -gt 0) {
    "REBOOT REQUIRED: $($stuckAndPatched.Count) server(s) patched over a week ago, still awaiting reboot"
} elseif ($pendingReboot.Count -gt 0) {
    "Pending Reboot Report — $($pendingReboot.Count) server(s) awaiting reboot"
} elseif ($criticalUptime.Count -gt 0) {
    "Uptime Report — $($criticalUptime.Count) server(s) over $uptimeCriticalDays days uptime"
} else {
    "Reboot Report — All servers current"
}

Send-MailMessage `
    -From $from -To $to `
    -Subject $subject `
    -Body $emailBody `
    -SmtpServer $smtpServer -Port $smtpPort `
    -UseSsl -Credential $credential

Update these lines:

  • $from / $to / $username / $password — your email details

  • $smtpServer — your SMTP server (e.g. smtp.office365.com)

  • $servers — leave empty to auto-discover from AD, or list explicitly

  • $uptimeWarningDays / $uptimeCriticalDays — 60 and 120 are reasonable

Requirements: PowerShell remoting enabled on targets, and an account with local admin rights on them. AD discovery needs the ActiveDirectory module on the machine running the script.

THE SIX PLACES WINDOWS HIDES THIS

There is no single "is a reboot pending" API. Windows records it in several unrelated places depending on what triggered it, which is exactly why most people check one location and get a false negative.

Component Based Servicing — the main one. Set when servicing operations stage changes that need a restart.

Windows Update\Auto Update\RebootRequired — set by the Windows Update client specifically.

PendingFileRenameOperations — files locked by a running process that will be replaced on boot. This is the one that catches software installs and in-use DLL replacements that nothing else flags.

Pending computer rename — the active name and configured name disagree. Usually means someone renamed the box and never rebooted.

Pending domain join / SPN set — Netlogon staged a domain operation.

Configuration Manager client — SCCM tracks its own state separately via WMI and will happily disagree with the registry.

The script checks all six and tells you which one fired. That matters, because a PendingFileRenameOperations flag from a software install is a different conversation than Component Based Servicing after a patch cycle.

THE SECTION THAT ACTUALLY MATTERS

Most of the email is inventory. The part worth reading is PATCHED BUT NOT PROTECTED at the top.

That section lists servers where both things are true: a reboot is pending, and the last hotfix went on more than seven days ago. Those machines have had a security update staged on disk for over a week without it taking effect.

Your patching dashboard counts them as compliant. Your vulnerability scanner may well still flag them, which is where the awkward meeting comes from — "patching says we're done, the scanner says we're not, who's wrong?" Neither. They're measuring different things, and the gap between them is this list.

A server that got patched yesterday and hasn't rebooted yet is normal, and it won't appear in that section. One that got patched three weeks ago and hasn't rebooted is a problem somebody forgot about.

THE UPTIME NUMBERS ARE A SEPARATE CONVERSATION

Long uptime isn't automatically bad. Some workloads legitimately run for months and there are environments where a 200-day uptime is a point of pride.

But a server nobody has rebooted in four months is a server nobody has tested rebooting in four months. You don't know whether the services come back cleanly. You don't know whether that manual change somebody made in June survives a restart because it was never written to config. You don't know if the boot disk still boots.

That uncertainty compounds. The longer it goes, the more nervous everyone gets about the reboot, so it gets deferred further, which makes it riskier. The only exit is a planned reboot during a window you chose rather than one the hardware chose for you.

If you have servers on the CRITICAL list that you're genuinely afraid to restart, that fear is the finding. Those are the ones to schedule first, in a window, with someone watching.

SETTING UP TASK SCHEDULER

  1. Press Windows key → type Task Scheduler → open it

  2. Click "Create Basic Task" → name it Pending Reboot Report

  3. Trigger: Weekly → Thursday → 7:00am

  4. Action: Start a program

  5. Program: powershell.exe

  6. Arguments:

-ExecutionPolicy Bypass -File "C:\Scripts\PendingRebootReport.ps1"
  1. Click Next → Finish

Under Properties → General, check "Run with highest privileges" and use an account with remote admin rights across your fleet.

Thursday is deliberate. Patch Tuesday lands Tuesday, most environments apply updates Tuesday night or Wednesday, and Thursday morning is when you find out which machines didn't come back properly — with the weekend still available as a maintenance window. Running it Monday means you've already lost that option.

TEST IT

Run it as administrator:

powershell

powershell.exe -ExecutionPolicy Bypass -File "C:\Scripts\PendingRebootReport.ps1"

Open C:\Logs\PendingRebootReport.csv and sort by PendingReboot. The CSV is the useful artifact here — it's the thing you paste into a change request to justify a maintenance window.

To check a single machine quickly before running the full sweep:

powershell

Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending"
Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"
(Get-CimInstance Win32_OperatingSystem).LastBootUpTime

If servers come back UNREACHABLE, enable remoting on each target:

powershell

Enable-PSRemoting -Force

Note that the unreachable list is itself useful. A server that was in last week's report and is unreachable this week is either decommissioned and still in AD, or it's down and nobody noticed.

A NOTE ON AUTOMATING THE REBOOT ITSELF

You'll be tempted. Don't — not from this script.

Rebooting is the one action in this series with real blast radius, and a scheduled task that restarts servers based on a registry key will eventually restart a database mid-transaction or a file server at 9am because the discovery picked up a machine you didn't expect.

If you want to move toward automated reboots, the path is: report first for a few weeks, build confidence in the list, then add an explicit opt-in. Something like a dedicated AD group or an OU containing only servers you've agreed can self-reboot, and have the script act on membership of that group rather than on the pending flag alone.

The manual command, for when you've decided:

powershell

Restart-Computer -ComputerName "SERVER01" -Force -Wait -For PowerShell -Timeout 600

The -Wait -For PowerShell part is worth knowing — it blocks until the machine is actually back and accepting remoting, so you find out immediately if it didn't come up rather than discovering it later.

WHY THIS MATTERS

The distance between "we applied the patch" and "the patch is in effect" is a reboot, and that distance is invisible on every dashboard that reports patch compliance. It's one of the few places where your reporting can be accurate and your security posture can still be wrong.

It's also a rare case where a report costs almost nothing and resolves a recurring argument. When the vulnerability scan disagrees with the patching report, this CSV tells you exactly which machines account for the difference and why.

The uptime side is the quieter benefit. Knowing which servers haven't been restarted in four months tells you where your undocumented changes and untested boot paths are hiding, long before an unplanned outage finds them for you.

THIS WEEK'S ACTION

Run it once against your fleet today. Read only the PATCHED BUT NOT PROTECTED section. If there's anything on that list, those servers go into this weekend's maintenance window — you now have the CSV to justify it.

Reply to this email if you hit any issues — I read every reply.

Automate & Operate — automateandoperate.com

How 2M+ Professionals Stay Ahead on AI

AI is moving fast and most people are falling behind. 

The Rundown AI keeps you ahead of the curve. 

It's a free AI newsletter that keeps you up-to-date on the latest AI news, and teaches you how to apply it in just 5 minutes a day.

Plus, complete the quiz after signing up and they’ll recommend the best AI tools, guides, and courses — tailored to your needs.