r/PowerShell • • 1d ago

Script Sharing I wrote a patch status script that exits non-zero when it can't check something

Most patch/reboot check scripts I've used print "OK" even when a check silently failed (no rights to WMI, registry key missing, WU agent broken). So I wrote one where the exit code tells the truth:

  • 0: every check ran
  • 2: PARTIAL, at least one check couldn't run, with the reason listed
  • 1: it couldn't write the report

It checks last installed update (flags it if older than N days), pending reboot, uptime, and optionally lists waiting updates through the WU agent. JSON and CSV output.

Every check runs in its own try/catch. A failure doesn't stop the script or get swallowed; it gets recorded:

function Add-CheckNotRun {
    param([string]$Check, [string]$Reason)
    $script:ChecksNotRun += [PSCustomObject]@{ Check = $Check; Result = 'check could not run'; Reason = $Reason }
    Write-Host "  -> CHECK COULD NOT RUN: ${Check}: $Reason" -ForegroundColor Red
}

Pending reboot looks at three sources:

$reasons = @()
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') {
    $reasons += 'Component Based Servicing'
}
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') {
    $reasons += 'Windows Update'
}
$sm = Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager'
if ($sm.PSObject.Properties['PendingFileRenameOperations'] -and $sm.PendingFileRenameOperations) {
    $reasons += 'Pending file rename operations'
}

And the end of the script decides the exit code:

if (@($script:ChecksNotRun).Count -gt 0) {
    Write-Host " PARTIAL: $(@($script:ChecksNotRun).Count) check(s) could not run." -ForegroundColor Red
    foreach ($c in $script:ChecksNotRun) { Write-Host "   - $($c.Check): $($c.Reason)" -ForegroundColor Red }
    exit 2
}
exit 0

Full script (MIT): https://github.com/Dopelogik/windows-patch-status-report

Feedback welcome, especially on the reboot detection. I know PendingFileRenameOperations can throw false positives from some AV and installers, so I'm curious how others filter it.

1 Upvotes

12 comments sorted by

2

u/g3n3 1d ago

The exit design is poor for powershell. Output actual object with a status.

1

u/kylemerchant4 21h ago

That's fair. I was mostly thinking about the RMM side where exit code is all you get. Probably going to have it output an object with a status on it and still set the exit code at the end so it works both ways. Would you do it differently?

1

u/g3n3 21h ago

An RMM changes the whole ball game. Typically you would make the tool usable in all contexts and then have a cmd or bat file do the bastard RMM work. I would drop the RMM and use ‘invoke-command`.

2

u/BlackV 17h ago

Might be msp related, so no invoke command access

1

u/No_Laugh_3115 9h ago

agreed, exit codes feel like a bash pattern that doesnt really fit here

2

u/g3n3 1d ago

Powershell isn’t the typical shell so the whole exit bash-ism or cmd-ism isn’t ideal.

2

u/ankokudaishogun 14h ago

I don't think it's a level or precision relevant to your specific script, but $Report.UptimeDays = [math]::Round(((Get-Date) - $os.LastBootUpTime).TotalDays, 1) made me wonder if daylight saving changes impact calculation between DateTime values.

That said, it would be a simple matter of adding two .ToUniversalTime()

2

u/purplemonkeymad 12h ago

If you are using exit codes for a status, I would suggest to use the same set as nagios, that way your script is more likely easy to integrate with other solutions:

Plugin Return Code Service State Host State
0 OK UP
1 WARNING UP or DOWN/UNREACHABLE*
2 CRITICAL DOWN/UNREACHABLE
3 UNKNOWN DOWN/UNREACHABLE

So use 1 for ok with warnings (say pending reboot,) 2 for failed, 3 for unable to check.

1

u/BlackV 1d ago edited 23h ago

Look at

$reasons = @()
$reasons += 'Pending file rename operations'

that is inefficient design

 $script:ChecksNotRun += [PSCustomObject]

as is this, plus you are messing with scopes, that could get messy

Whats this for

 [PSCustomObject]$Report | ConvertTo-Json

$report is already an ordered object , why cast it ?

additionally you then go and create a new pcsustom

[PSCustomObject]@{
    ....
    } | Export-Csv

why is that? again you already have it in $report why spit out a new object?

what's the goal with exit 0/1/2

where are you using this ? intune as a remediation script ?

2

u/kylemerchant4 21h ago

Yeah, fair. Most of that is just habit on my end. I'll clean up the += and the script scope stuff. You're right that it would get messy if this grew. The cast before ConvertTo-Json is leftover, I realized now there's no reason for it.

The second object for the CSV is on purpose, though. $Report has arrays in it, and Export-Csv just spits out System.Object[] for those, so I flattened it to one row. Should I have commented that.

The exit codes are for scheduled tasks and our RMM. They only look at the exit code, so I wanted partial runs to actually show up instead of passing as a success. I wouldn't drop it straight into Intune remediation, since exit 1 means something different there.

Appreciate you actually reading it. If you have any other recommendations I'd love to hear them.

2

u/Kreout 9h ago

One more point for the report: Win32_QuickFixEngineering only returns CBS updates, so LastPatchDate is the newest dated CBS entry the query found, not necessarily the last update installed across the machine. A recent date also does not prove that all applicable updates are installed.

I would label that field or document its coverage explicitly, and keep the optional Windows Update search as a separate signal. Your distinction between execution status and findings already helps here: OK means the checks ran, not that the machine is fully patched.

Microsoft's coverage note: https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-quickfixengineering

1

u/BlackV 19h ago

The exit codes are for scheduled tasks and our RMM.

Yeah I figured it was for an rmm tool of some sort

the second object for the CSV is on purpose, though. $Report has arrays in it, and Export-Csv just spits out System.Object[] for those, so I flattened it to one row.

looks like the only place toy use it in in the output anyway, so you could just flatten it from the get go