the problem
"Are we patched?" shouldn't need SCCM.
No WSUS, no SCCM, no PowerShell — just WMI queried directly from Ruby.
windows-only collector
Win32_QuickFixEngineering
The same WMI class PowerShell’s Get-HotFix reads from.
patch_compliance.rb
WIN32OLE.connect("winmgmts://./root/cimv2")
SELECT HotFixID, Description, InstalledOn
FROM Win32_QuickFixEngineering
the pattern
Windows-only I/O, plain-Ruby logic
PatchComplianceAnalyzer takes plain hashes — zero dependency on WIN32OLE, fully testable on Linux.
patch_compliance.rb
class PatchComplianceAnalyzer
def status; …; end
class WmiPatchCollector # Windows-only
terminal output
WARNING at 47 days since patch
Reboot pending, days since last patch, and a CSV export in one run.
ruby patch_compliance.rb –csv report.csv
Patch compliance status : WARNING
Days since last patch : 47
Reboot pending : false
classification
OK · WARNING · CRITICAL
Configurable thresholds (30/60 days by default) turn a date into a verdict.
CRITICAL

62 days since last patch AND reboot pending — past the 60-day threshold
summary
8 test groups · 0 Windows box needed
The analyzer and WMI date parser are both pure functions, tested entirely on Linux.
8
test groups
2
reboot checks
0
Windows box needed
next steps
Fleet-wide compliance
INTEGRATE — loop over hostnames via winmgmts://host
EXTEND — cross-check against MSRC CVE feed

Part of our Ruby for DevOps series — practical Ruby scripts that solve real system-administration problems.

ruby for devops

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.

Architecture diagram showing WIN32OLE querying Win32_QuickFixEngineering and StdRegProv, feeding a pure-Ruby PatchComplianceAnalyzer that outputs OK, WARNING, or CRITICAL
before you start

Prerequisites

  • Ruby 3.x for Windows (the RubyInstaller for Windows build, which bundles win32ole in 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.

live code

The Code

Save this as patch_compliance.rb:

patch_compliance.rbruby
#!/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
walkthrough

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.

verify

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:

test_patch_compliance.rbruby
#!/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:

$ ruby test_patch_compliance.rb
Test 1: status = :ok when most recent patch is within warning window
ok – days_since_last_patch is 12
ok – status is :ok
ok – most_recent_hotfix picks the newer KB5001
Test 2: status = :warning between warning and critical thresholds
ok – days_since_last_patch is 47
ok – status is :warning
Test 3: status = :critical past the critical threshold
ok – days_since_last_patch &gt; 60
ok – status is :critical
ok – pending_reboot? is true
Test 4: status = :unknown when there is no hotfix data at all
ok – days_since_last_patch is nil
ok – status is :unknown
ok – most_recent_hotfix is nil
Test 5: custom thresholds are respected
ok – 21 days old is critical under a 10-day threshold
Test 6: report() hash shape and CSV row
ok – report has all expected keys
ok – csv row has 6 columns matching csv_header
Test 7: WmiPatchCollector.parse_wmi_date handles real WMI timestamp format
ok – parses year/month/day from WMI datetime string
ok – nil input returns nil
ok – empty string returns nil
ok – garbage input returns nil safely
Test 8: WmiPatchCollector.windows? correctly detects this sandbox is Linux
ok – windows? is false on Linux CI/sandbox
ALL TESTS PASSED

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.

results

Example Output

Terminal output of ruby patch_compliance.rb showing a WARNING status, 47 days since last patch, and a CSV export message
if something breaks

Troubleshooting

common issues
  • “cannot load such file — win32ole” — you’re running under a non-Windows Ruby build (e.g. WSL or a Linux/macOS Ruby). win32ole is 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 allWin32_QuickFixEngineering only reports hotfixes installed via Windows Update/WUSA, not every update mechanism. Cross-check with Get-HotFix in 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.
next steps

Extending the Script

extend this further
  • Add a third reboot-pending check for PendingFileRenameOperations under SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate.
  • Loop collect_hotfixes over a list of hostnames (WMI supports remote connections via winmgmts://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 HotFixID values against the Microsoft Security Response Center API to flag machines missing a specific critical security patch, not just “any patch in N days.”