Get-HotFix reads from.PatchComplianceAnalyzer takes plain hashes — zero dependency on WIN32OLE, fully testable on Linux.EXTEND — cross-check against MSRC CVE feed
Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.
The Problem
“Are all our Windows boxes patched?” is a simple question that’s surprisingly hard to answer at scale. WSUS and SCCM dashboards exist, but plenty of shops run a handful of standalone Windows servers with no central patch management, or need a lightweight compliance check that runs from a scheduled task and reports pass/fail without standing up more infrastructure. You need three things: when the last update was actually installed, whether a reboot is sitting there waiting to finish the job, and a clear compliance verdict against your patch SLA.
This tutorial builds patch_compliance.rb, a Ruby script that queries WMI directly — no PowerShell, no extra gems — to answer all three, and classifies the machine as OK, WARNING, or CRITICAL based on configurable thresholds.

Prerequisites
- Ruby 3.x for Windows (the RubyInstaller for Windows build, which bundles
win32olein the standard library — no gem install needed) - Windows 10/11 or Windows Server 2016+
- Standard user privileges are enough to query WMI for this data; no admin rights required
A note on testing: win32ole only exists on Windows, so this script is deliberately split into a Windows-only WMI collector and a plain-Ruby analyzer. The analyzer is what was actually tested for this article (in a Linux sandbox); the WMI collector is a thin layer that hands it real data on a Windows box. This split is a generally useful pattern any time you’re writing Windows-only automation you’d like to keep testable.
The Code
Save this as patch_compliance.rb:
#!/usr/bin/env ruby
# frozen_string_literal: true
# patch_compliance.rb
#
# Windows patch/update compliance reporting using WMI -- no PowerShell,
# no extra gems. Answers three questions a DevOps/sysadmin needs for
# every Windows box in the fleet:
#
# 1. When was the last update actually installed?
# 2. Is the machine waiting on a reboot to finish applying updates?
# 3. Based on our patch SLA, is this machine compliant, at warning, or
# critical?
#
# Design note: WIN32OLE only exists on Windows, so this script is split
# into two halves:
#
# - PatchComplianceAnalyzer: pure Ruby, takes plain data structures
# (hotfix records + a pending-reboot flag) and produces the compliance
# report. Zero platform dependencies -- fully unit-testable anywhere,
# which is how it was verified for this tutorial (see
# test_patch_compliance.rb).
#
# - WmiPatchCollector: the thin Windows-only layer that actually talks
# to WMI via WIN32OLE and feeds real data into the analyzer above.
#
# Prerequisites: Ruby 3.x on Windows 10/11 or Windows Server 2016+.
# No gems required -- win32ole ships with the standard Ruby Windows
# installer (RubyInstaller for Windows).
require 'date'
require 'csv'
# ---------------------------------------------------------------------------
# Pure Ruby analysis layer (platform-independent, fully testable)
# ---------------------------------------------------------------------------
class PatchComplianceAnalyzer
Hotfix = Struct.new(:hotfix_id, :description, :installed_on, keyword_init: true)
DEFAULT_THRESHOLDS = { warning_days: 30, critical_days: 60 }.freeze
def initialize(hotfixes:, pending_reboot:, thresholds: DEFAULT_THRESHOLDS, today: Date.today)
@hotfixes = hotfixes.map { |h| h.is_a?(Hotfix) ? h : Hotfix.new(**h) }
@pending_reboot = pending_reboot
@thresholds = thresholds
@today = today
end
# Returns the single most recent hotfix (by InstalledOn), or nil if the
# machine has no recorded hotfixes at all (itself worth flagging).
def most_recent_hotfix
@hotfixes.compact.reject { |h| h.installed_on.nil? }
.max_by(&:installed_on)
end
def days_since_last_patch
latest = most_recent_hotfix
return nil unless latest
(@today - latest.installed_on).to_i
end
# :ok, :warning, :critical, or :unknown (no patch data at all)
def status
days = days_since_last_patch
return :unknown if days.nil?
return :critical if days > @thresholds[:critical_days]
return :warning if days > @thresholds[:warning_days]
:ok
end
def pending_reboot?
@pending_reboot
end
# Builds the full report as a plain hash -- easy to render as text,
# JSON, or CSV from the same data.
def report
{
status: status,
days_since_last_patch: days_since_last_patch,
most_recent_hotfix: most_recent_hotfix&.hotfix_id,
most_recent_description: most_recent_hotfix&.description,
pending_reboot: pending_reboot?,
total_hotfixes: @hotfixes.length,
generated_at: Time.now
}
end
def to_csv_row
r = report
[r[:status], r[:days_since_last_patch], r[:most_recent_hotfix],
r[:pending_reboot], r[:total_hotfixes], r[:generated_at]]
end
def self.csv_header
%w[status days_since_last_patch most_recent_hotfix pending_reboot total_hotfixes generated_at]
end
end
# ---------------------------------------------------------------------------
# Windows-only WMI data collection layer
# ---------------------------------------------------------------------------
module WmiPatchCollector
module_function
def windows?
!!(RUBY_PLATFORM =~ /mingw|mswin|cygwin/)
end
# Returns an array of hash records suitable for PatchComplianceAnalyzer.new(hotfixes: ...)
def collect_hotfixes(host: '.')
require 'win32ole'
wmi = WIN32OLE.connect("winmgmts://#{host}/root/cimv2")
results = wmi.ExecQuery('SELECT HotFixID, Description, InstalledOn FROM Win32_QuickFixEngineering')
hotfixes = []
results.each do |hf|
installed_on = parse_wmi_date(hf.InstalledOn)
hotfixes << {
hotfix_id: hf.HotFixID,
description: hf.Description,
installed_on: installed_on
}
end
hotfixes
end
# Checks the two most common "a reboot is needed to finish patching"
# registry indicators via the StdRegProv WMI class -- avoids requiring
# the separate win32/registry gem.
def pending_reboot?(host: '.')
require 'win32ole'
reg = WIN32OLE.connect("winmgmts://#{host}/root/default:StdRegProv")
hklm = 2147483650 # HKEY_LOCAL_MACHINE
cbs_key = 'SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Component Based Servicing\\RebootPending'
wu_key = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\WindowsUpdate\\Auto Update\\RebootRequired'
cbs_present = reg.EnumKey(hklm, cbs_key.split('\\')[0..-2].join('\\'))[1] rescue nil
key_exists = lambda do |subkey|
names = []
result = reg.EnumKey(hklm, subkey, names)
result.zero?
end
key_exists.call(cbs_key) || key_exists.call(wu_key)
end
# WMI datetime format looks like "20260715143012.000000-420"; this parses
# just the date portion into a Ruby Date.
def parse_wmi_date(wmi_timestamp)
return nil if wmi_timestamp.nil? || wmi_timestamp.to_s.strip.empty?
match = wmi_timestamp.to_s.match(/\A(\d{4})(\d{2})(\d{2})/)
return nil unless match
Date.new(match[1].to_i, match[2].to_i, match[3].to_i)
rescue ArgumentError
nil
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
unless WmiPatchCollector.windows?
warn 'This script queries WMI and only runs on Windows. ' \
'Exiting without collecting data (see the tutorial for how the ' \
'analyzer logic is tested cross-platform).'
exit 1
end
hotfixes = WmiPatchCollector.collect_hotfixes
reboot_pending = WmiPatchCollector.pending_reboot?
analyzer = PatchComplianceAnalyzer.new(hotfixes: hotfixes, pending_reboot: reboot_pending)
report = analyzer.report
puts "Patch compliance status : #{report[:status].to_s.upcase}"
puts "Days since last patch : #{report[:days_since_last_patch] || 'unknown'}"
puts "Most recent hotfix : #{report[:most_recent_hotfix] || 'none found'}"
puts "Reboot pending : #{report[:pending_reboot]}"
puts "Total hotfixes recorded : #{report[:total_hotfixes]}"
if ARGV.include?('--csv')
path = ARGV[ARGV.index('--csv') + 1] || 'patch_compliance.csv'
CSV.open(path, 'w') do |csv|
csv << PatchComplianceAnalyzer.csv_header
csv << analyzer.to_csv_row
end
puts "CSV written to #{path}"
end
exit(report[:status] == :critical ? 2 : (report[:status] == :warning ? 1 : 0))
end
Step-by-Step Walkthrough
PatchComplianceAnalyzer — the testable core
This class takes plain data (an array of hotfix hashes with hotfix_id, description, and installed_on, plus a pending_reboot boolean) and has zero dependency on WMI or the Windows platform. most_recent_hotfix finds the newest install date, days_since_last_patch computes the gap against “today” (injectable for testing), and status classifies the result against warning_days / critical_days thresholds (30/60 by default, both configurable).
WmiPatchCollector — the Windows-only data source
collect_hotfixes connects to WMI via WIN32OLE.connect("winmgmts://./root/cimv2") and runs a SELECT HotFixID, Description, InstalledOn FROM Win32_QuickFixEngineering query — the same class Windows itself uses to back Get-HotFix in PowerShell. pending_reboot? checks the two most common reboot-pending indicators (Component-Based Servicing’s RebootPending key, and Windows Update’s RebootRequired key) via the StdRegProv WMI class, which lets you query the registry through WMI without pulling in the separate win32-registry gem.
Parsing WMI’s date format
WMI timestamps look like 20260715143012.000000-420 — not ISO 8601. parse_wmi_date extracts just the YYYYMMDD prefix with a regex and builds a Date, returning nil safely for blank or malformed input rather than raising.
Tying it together in the CLI
The bottom of the script checks WmiPatchCollector.windows? (a simple RUBY_PLATFORM check) before doing anything WMI-related, collects hotfixes and the reboot flag, feeds them into the analyzer, prints a report, optionally writes a CSV with --csv path.csv, and exits with a status code (0/1/2 for OK/WARNING/CRITICAL) so it plugs neatly into a scheduled task or monitoring check.
Testing It (Cross-Platform)
Because PatchComplianceAnalyzer takes plain hashes instead of live WMI objects, and parse_wmi_date is a pure string-parsing function, both can be fully exercised without Windows at all:
#!/usr/bin/env ruby
# frozen_string_literal: true
# Tests for the platform-independent PatchComplianceAnalyzer half of
# patch_compliance.rb. WmiPatchCollector (the WIN32OLE half) can only be
# exercised on real Windows, but it's a thin data-gathering shim -- all of
# the actual compliance logic lives in the analyzer and is fully verified
# here, including the WMI-date parser against real WMI-formatted strings.
require_relative 'patch_compliance'
$failures = 0
def check(desc, cond)
if cond
puts " ok - #{desc}"
else
puts " FAIL - #{desc}"
$failures += 1
end
end
today = Date.new(2026, 7, 22)
puts 'Test 1: status = :ok when most recent patch is within warning window'
analyzer = PatchComplianceAnalyzer.new(
hotfixes: [
{ hotfix_id: 'KB5001', description: 'Cumulative Update', installed_on: Date.new(2026, 7, 10) },
{ hotfix_id: 'KB5002', description: 'Security Update', installed_on: Date.new(2026, 6, 1) }
],
pending_reboot: false,
today: today
)
check('days_since_last_patch is 12', analyzer.days_since_last_patch == 12)
check('status is :ok', analyzer.status == :ok)
check('most_recent_hotfix picks the newer KB5001', analyzer.most_recent_hotfix.hotfix_id == 'KB5001')
puts "\nTest 2: status = :warning between warning and critical thresholds"
analyzer = PatchComplianceAnalyzer.new(
hotfixes: [{ hotfix_id: 'KB6000', description: 'Update', installed_on: Date.new(2026, 6, 5) }],
pending_reboot: false,
today: today
)
check('days_since_last_patch is 47', analyzer.days_since_last_patch == 47)
check('status is :warning', analyzer.status == :warning)
puts "\nTest 3: status = :critical past the critical threshold"
analyzer = PatchComplianceAnalyzer.new(
hotfixes: [{ hotfix_id: 'KB1000', description: 'Old Update', installed_on: Date.new(2026, 4, 1) }],
pending_reboot: true,
today: today
)
check('days_since_last_patch > 60', analyzer.days_since_last_patch > 60)
check('status is :critical', analyzer.status == :critical)
check('pending_reboot? is true', analyzer.pending_reboot? == true)
puts "\nTest 4: status = :unknown when there is no hotfix data at all"
analyzer = PatchComplianceAnalyzer.new(hotfixes: [], pending_reboot: false, today: today)
check('days_since_last_patch is nil', analyzer.days_since_last_patch.nil?)
check('status is :unknown', analyzer.status == :unknown)
check('most_recent_hotfix is nil', analyzer.most_recent_hotfix.nil?)
puts "\nTest 5: custom thresholds are respected"
analyzer = PatchComplianceAnalyzer.new(
hotfixes: [{ hotfix_id: 'KB7000', description: 'Update', installed_on: Date.new(2026, 7, 1) }],
pending_reboot: false,
thresholds: { warning_days: 5, critical_days: 10 },
today: today
)
check('21 days old is critical under a 10-day threshold', analyzer.status == :critical)
puts "\nTest 6: report() hash shape and CSV row"
analyzer = PatchComplianceAnalyzer.new(
hotfixes: [{ hotfix_id: 'KB5001', description: 'Cumulative Update', installed_on: Date.new(2026, 7, 10) }],
pending_reboot: false,
today: today
)
report = analyzer.report
check('report has all expected keys',
%i[status days_since_last_patch most_recent_hotfix most_recent_description
pending_reboot total_hotfixes generated_at].all? { |k| report.key?(k) })
row = analyzer.to_csv_row
check('csv row has 6 columns matching csv_header', row.length == PatchComplianceAnalyzer.csv_header.length)
puts "\nTest 7: WmiPatchCollector.parse_wmi_date handles real WMI timestamp format"
parsed = WmiPatchCollector.parse_wmi_date('20260715143012.000000-420')
check('parses year/month/day from WMI datetime string', parsed == Date.new(2026, 7, 15))
check('nil input returns nil', WmiPatchCollector.parse_wmi_date(nil).nil?)
check('empty string returns nil', WmiPatchCollector.parse_wmi_date('').nil?)
check('garbage input returns nil safely', WmiPatchCollector.parse_wmi_date('not-a-date').nil?)
puts "\nTest 8: WmiPatchCollector.windows? correctly detects this sandbox is Linux"
check('windows? is false on Linux CI/sandbox', WmiPatchCollector.windows? == false)
puts "\n#{$failures.zero? ? 'ALL TESTS PASSED' : "#{$failures} TEST(S) FAILED"}"
exit($failures.zero? ? 0 : 1)
Output from this suite, run in a Linux sandbox:
On the actual Windows target, only the thin collect_hotfixes / pending_reboot? WMI calls remain to verify manually — and because they’re just data collection with no branching logic, a quick visual check of their output against Get-HotFix in PowerShell is enough.
Example Output

Troubleshooting
- “cannot load such file — win32ole” — you’re running under a non-Windows Ruby build (e.g. WSL or a Linux/macOS Ruby).
win32oleis only shipped with the Windows RubyInstaller build. - WMI connection fails with an RPC or access-denied error — check that the “Windows Management Instrumentation” service is running (
services.msc) and, for remote hosts, that WMI/DCOM traffic is allowed through the firewall. - No hotfixes returned at all —
Win32_QuickFixEngineeringonly reports hotfixes installed via Windows Update/WUSA, not every update mechanism. Cross-check withGet-HotFixin PowerShell; if that’s also empty, the box likely hasn’t been patched through the standard channel. - Reboot-pending check misses a case — there are more than two registry locations Windows can use to flag a pending reboot (SCCM and pending file rename operations add their own). Treat
pending_reboot?as covering the two most common cases, not exhaustive.
Extending the Script
- Add a third reboot-pending check for
PendingFileRenameOperationsunderSOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate. - Loop
collect_hotfixesover a list of hostnames (WMI supports remote connections viawinmgmts://hostname/root/cimv2) to build a fleet-wide compliance report. - Feed the CSV output into a scheduled email or Slack summary so patch drift gets surfaced automatically instead of requiring someone to check.
- Cross-reference
HotFixIDvalues against the Microsoft Security Response Center API to flag machines missing a specific critical security patch, not just “any patch in N days.”


