A running Windows Update service isn’t the same thing as a patched machine. Here’s a Ruby auditor that talks directly to the Windows Update Agent COM API and WMI to answer the question that actually matters: is this box current, and how do you know?
Step through the build below:
“Windows Update is turned on” is not the same thing as “this machine is patched.”
The service can be running, show green in Services, and still have silently stopped applying anything weeks ago
— a stuck download, a broken update agent, a GPO that quietly disabled the wrong thing. A dashboard that only
checks “is the service running” will miss all of that. What actually matters is: are there uninstalled updates
right now, how severe are they, is a reboot blocking updates that already downloaded from taking effect, and when
did a patch last actually land?
Answering those questions from Ruby means going through the same API PowerShell’s Get-WindowsUpdate
-style tooling uses under the hood: the Windows Update Agent COM API (Microsoft.Update.Session), plus
WMI’s Win32_QuickFixEngineering for install history. No PowerShell process, no third-party gems —
just WIN32OLE talking directly to COM and WMI.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# windows_update_audit.rb
#
# Audits Windows Update compliance on a live host via the Windows Update
# Agent COM API and WMI -- no PowerShell, no third-party gems. It answers
# the three questions a patch-compliance dashboard actually cares about:
#
# 1. Are there uninstalled updates, and how severe are they (Critical /
# Important / Moderate / Low, per Microsoft's MSRC rating)?
# 2. Is the machine sitting on a pending reboot that's blocking updates
# already downloaded from taking effect?
# 3. How long has it been since the last hotfix was actually installed?
# (A quiet Windows Update service can look "compliant" while
# silently having stopped working weeks ago -- staleness catches
# that a green "0 pending updates" count would miss.)
#
# Because the Windows Update Agent COM API (Microsoft.Update.Session) and
# Win32_QuickFixEngineering only exist on Windows, the analysis logic is
# split from the collection logic: WmiCollector gathers a SystemSnapshot
# from the live machine (Windows only), and Analyzer classifies any
# SystemSnapshot -- live or loaded from a --fixture JSON file -- into
# OK/WARN/CRIT. That split is also a real feature, not just a test seam:
# `--export` lets a scheduled task on each Windows box drop a snapshot to
# a shared folder, and `--fixture` lets a central Linux/macOS box roll
# those snapshots up into one fleet-wide compliance report.
#
# Usage:
# ruby windows_update_audit.rb [--export FILE] (Windows, live)
# ruby windows_update_audit.rb --fixture FILE [--json] (any OS, offline)
#
require 'json'
require 'time'
require 'optparse'
# ---------------------------------------------------------------------------
# Plain data structures shared by the live collector and the fixture loader,
# so Analyzer never has to know or care where a snapshot came from.
# ---------------------------------------------------------------------------
PendingUpdate = Struct.new(:title, :kb_ids, :severity, :is_downloaded, keyword_init: true) do
def to_h_public
{ title: title, kb_ids: kb_ids, severity: severity, is_downloaded: is_downloaded }
end
end
Hotfix = Struct.new(:hotfix_id, :installed_on, :description, keyword_init: true)
SystemSnapshot = Struct.new(:hostname, :pending_updates, :hotfixes, :reboot_required,
:collected_at, keyword_init: true) do
def self.from_h(h)
new(
hostname: h['hostname'] || h[:hostname],
reboot_required: h['reboot_required'].nil? ? h[:reboot_required] : h['reboot_required'],
collected_at: (h['collected_at'] || h[:collected_at]),
pending_updates: (h['pending_updates'] || h[:pending_updates] || []).map do |u|
PendingUpdate.new(title: u['title'] || u[:title], kb_ids: u['kb_ids'] || u[:kb_ids],
severity: u['severity'] || u[:severity],
is_downloaded: u['is_downloaded'].nil? ? u[:is_downloaded] : u['is_downloaded'])
end,
hotfixes: (h['hotfixes'] || h[:hotfixes] || []).map do |hf|
Hotfix.new(hotfix_id: hf['hotfix_id'] || hf[:hotfix_id],
installed_on: hf['installed_on'] || hf[:installed_on],
description: hf['description'] || hf[:description])
end
)
end
def to_h_public
{
hostname: hostname,
collected_at: collected_at,
reboot_required: reboot_required,
pending_updates: pending_updates.map(&:to_h_public),
hotfixes: hotfixes.map(&:to_h)
}
end
end
# ---------------------------------------------------------------------------
# WmiCollector: talks to a live Windows machine via WIN32OLE. Only ever
# instantiated/called when running on Windows -- see the CLI section.
# ---------------------------------------------------------------------------
class WmiCollector
def collect
require 'win32ole'
session = WIN32OLE.new('Microsoft.Update.Session')
searcher = session.CreateUpdateSearcher
result = searcher.Search('IsInstalled=0 and IsHidden=0')
pending = []
result.Updates.each do |u|
kb_ids = []
u.KBArticleIDs.each { |kb| kb_ids << "KB#{kb}" }
pending << PendingUpdate.new(
title: u.Title,
kb_ids: kb_ids,
severity: u.MsrcSeverity.nil? || u.MsrcSeverity.empty? ? 'Unspecified' : u.MsrcSeverity,
is_downloaded: u.IsDownloaded
)
end
sysinfo = WIN32OLE.new('Microsoft.Update.SystemInfo')
reboot_required = sysinfo.RebootRequired
wmi = WIN32OLE.connect('winmgmts://./root/cimv2')
hotfixes = []
wmi.ExecQuery('SELECT HotFixID, InstalledOn, Description FROM Win32_QuickFixEngineering').each do |h|
hotfixes << Hotfix.new(hotfix_id: h.HotFixID, installed_on: h.InstalledOn, description: h.Description)
end
SystemSnapshot.new(
hostname: ENV['COMPUTERNAME'] || 'localhost',
pending_updates: pending,
hotfixes: hotfixes,
reboot_required: reboot_required,
collected_at: Time.now.utc.iso8601
)
end
end
# ---------------------------------------------------------------------------
# Analyzer: pure logic, no WMI/COM dependency, fully unit-testable on any
# platform against fixture snapshots. Classifies a SystemSnapshot into
# OK/WARN/CRIT with the specific reasons that drove the verdict.
# ---------------------------------------------------------------------------
class Analyzer
SEVERITY_ORDER = { 'Critical' => 3, 'Important' => 2, 'Moderate' => 1, 'Low' => 0, 'Unspecified' => 0 }.freeze
def initialize(warn_days: 45, crit_days: 90)
@warn_days = warn_days
@crit_days = crit_days
end
Verdict = Struct.new(:status, :reasons, :days_since_last_patch, keyword_init: true)
def classify(snapshot)
reasons = []
status = 'OK'
critical_pending = snapshot.pending_updates.select { |u| SEVERITY_ORDER[u.severity].to_i >= 3 }
important_pending = snapshot.pending_updates.select { |u| SEVERITY_ORDER[u.severity].to_i == 2 }
unless critical_pending.empty?
status = 'CRIT'
reasons << "#{critical_pending.size} Critical-severity update(s) pending: " \
"#{critical_pending.map { |u| u.kb_ids.join('/') }.join(', ')}"
end
unless important_pending.empty?
status = worse(status, 'WARN')
reasons << "#{important_pending.size} Important-severity update(s) pending"
end
if snapshot.reboot_required
status = worse(status, 'WARN')
reasons << 'reboot pending -- downloaded updates are not yet active'
end
days_since_last_patch = compute_days_since_last_patch(snapshot.hotfixes)
if days_since_last_patch
if days_since_last_patch >= @crit_days
status = 'CRIT'
reasons << "#{days_since_last_patch} days since the last installed hotfix (>= #{@crit_days}-day CRIT threshold)"
elsif days_since_last_patch >= @warn_days
status = worse(status, 'WARN')
reasons << "#{days_since_last_patch} days since the last installed hotfix (>= #{@warn_days}-day WARN threshold)"
end
else
status = worse(status, 'WARN')
reasons << 'no hotfix install history found -- cannot confirm patching is active'
end
reasons << 'no issues found' if reasons.empty?
Verdict.new(status: status, reasons: reasons, days_since_last_patch: days_since_last_patch)
end
private
def worse(current, candidate)
order = { 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }
order[candidate] > order[current] ? candidate : current
end
# Win32_QuickFixEngineering's InstalledOn comes back as a locale-dependent
# date string (e.g. "8/12/2026") rather than a WMI CIM_DATETIME, so we
# parse defensively and skip anything we can't confidently read rather
# than raising and aborting the whole audit over one bad row.
def compute_days_since_last_patch(hotfixes)
dates = hotfixes.filter_map { |h| parse_installed_on(h.installed_on) }
return nil if dates.empty?
(Date.today - dates.max).to_i
end
# Win32_QuickFixEngineering.InstalledOn is a locale-dependent string, not
# a CIM_DATETIME -- on a US-locale Windows box it comes back M/D/YYYY
# (e.g. "7/10/2026" for July 10th). Ruby's Date.parse is the wrong tool
# here: for slash-separated dates it guesses D/M/Y, so "7/10/2026" comes
# back as October 7th, silently 3 months off. We parse M/D/Y explicitly
# first (the common case) and only fall back to Date.parse for ISO-ish
# strings a --fixture file or a non-US locale might supply.
def parse_installed_on(raw)
return nil if raw.nil? || raw.to_s.strip.empty?
str = raw.to_s.strip
begin
Date.strptime(str, '%m/%d/%Y')
rescue ArgumentError, Date::Error
begin
Date.parse(str)
rescue ArgumentError, Date::Error
nil
end
end
end
end
require 'date'
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = { json: false, warn_days: 45, crit_days: 90 }
parser = OptionParser.new do |opts|
opts.banner = 'Usage: ruby windows_update_audit.rb [--fixture FILE] [--export FILE] [options]'
opts.on('--fixture FILE', 'Analyze a previously-exported JSON snapshot instead of live WMI') { |v| options[:fixture] = v }
opts.on('--export FILE', 'Also write the live-collected snapshot to FILE as JSON') { |v| options[:export] = v }
opts.on('--warn-days N', Integer, 'Days-since-last-patch WARN threshold (default: 45)') { |v| options[:warn_days] = v }
opts.on('--crit-days N', Integer, 'Days-since-last-patch CRIT threshold (default: 90)') { |v| options[:crit_days] = v }
opts.on('--json', 'Emit machine-readable JSON') { options[:json] = true }
end
parser.parse!(ARGV)
snapshot =
if options[:fixture]
SystemSnapshot.from_h(JSON.parse(File.read(options[:fixture])))
elsif RUBY_PLATFORM =~ /mingw|mswin|windows/i
snap = WmiCollector.new.collect
File.write(options[:export], JSON.pretty_generate(snap.to_h_public)) if options[:export]
snap
else
warn "error: live WMI collection requires Windows (RUBY_PLATFORM=#{RUBY_PLATFORM}). " \
'Pass --fixture FILE to analyze a snapshot collected elsewhere.'
exit 2
end
verdict = Analyzer.new(warn_days: options[:warn_days], crit_days: options[:crit_days]).classify(snapshot)
if options[:json]
puts JSON.pretty_generate(
hostname: snapshot.hostname,
status: verdict.status,
days_since_last_patch: verdict.days_since_last_patch,
reboot_required: snapshot.reboot_required,
pending_update_count: snapshot.pending_updates.size,
reasons: verdict.reasons
)
else
puts "host: #{snapshot.hostname} status: #{verdict.status}"
puts "pending updates: #{snapshot.pending_updates.size} reboot required: #{snapshot.reboot_required} " \
"days since last patch: #{verdict.days_since_last_patch || 'unknown'}"
puts '-' * 70
verdict.reasons.each { |r| puts " - #{r}" }
end
exit({ 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[verdict.status])
end
The script is deliberately split into two halves that don’t know about each other’s
internals. WmiCollector is the only part that touches WIN32OLE, and it only runs on Windows —
it creates a Microsoft.Update.Session, asks its CreateUpdateSearcher for anything
matching "IsInstalled=0 and IsHidden=0", and separately queries Win32_QuickFixEngineering
over WMI for install history. Everything it gathers gets flattened into a plain SystemSnapshot struct
— no COM objects survive past this point.
Analyzer then classifies any SystemSnapshot, live or not, into OK/WARN/CRIT.
That separation isn’t just for testability (though it’s how this got tested at all in a Linux sandbox with no
Windows host available) — it’s a real operational feature: --export lets a scheduled task on
each Windows box drop a snapshot into a shared folder, and --fixture lets a central Linux or macOS
box roll dozens of those snapshots into one fleet-wide report without needing WMI access to every machine.
One genuine gotcha is called out directly in the code comments because it bit the first version of this script:
Win32_QuickFixEngineering.InstalledOn is a locale-formatted string, not a real WMI timestamp. On a
US-locale box it’s M/D/YYYY, and Ruby’s Date.parse guesses the wrong format for
slash-separated dates — it silently read “7/10/2026” as October 7th instead of July 10th during testing. The
fix is to parse with an explicit Date.strptime(str, '%m/%d/%Y') first and only fall back to
Date.parse for other formats.
$ ruby windows_update_audit_test.rb clean system with recent patching -> OK ok - status is OK ok - days_since_last_patch is 3 one Critical pending update -> CRIT regardless of anything else ok - status is CRIT ok - reason mentions Critical only Important pending update -> WARN, not CRIT ok - status is WARN reboot required with no pending updates -> WARN ok - status is WARN ok - reason mentions reboot patching stale beyond crit_days -> CRIT even with nothing pending ok - status is CRIT ok - days_since_last_patch > 90 no hotfix history at all -> WARN (cannot confirm patching is active) ok - status is WARN ok - days_since_last_patch is nil US-locale M/D/Y InstalledOn strings are parsed correctly (regression: not D/M/Y) ok - 7/10/2026 means July 10th (25 days before Aug 4), not Oct 7th Critical + Important + reboot + stale all combine and CRIT wins ok - status is CRIT ok - collects all 4 reasons 14/14 checks passed $ ruby windows_update_audit.rb --fixture test/fixtures/crit_critical_pending.json host: DB03 status: CRIT pending updates: 1 reboot required: false days since last patch: 7 ---------------------------------------------------------------------- - 1 Critical-severity update(s) pending: KB5041500 exit=2 $ ruby windows_update_audit.rb --fixture test/fixtures/crit_stale_patching.json host: LEGACY04 status: CRIT pending updates: 0 reboot required: false days since last patch: 214 ---------------------------------------------------------------------- - 214 days since the last installed hotfix (>= 90-day CRIT threshold) exit=2
Full script + README on GitHub: ruby-devops-toolkit/windows-update-audit
How it fits together
Prerequisites
- Ruby 3.0+ on Windows for live collection (uses the bundled
win32olelibrary,
which ships with Ruby on Windows — nothing extra to install). - Any OS for offline analysis via
--fixture— theAnalyzerclass
has zero Windows-specific dependencies. - No gems.
win32ole,json,optparse,date,
andtimeare all standard library. - Windows Update Agent must be present and functional on the target machine (standard on any
supported Windows version); no special permissions beyond a normal user session are required to search
for updates.
The full script
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# windows_update_audit.rb
#
# Audits Windows Update compliance on a live host via the Windows Update
# Agent COM API and WMI -- no PowerShell, no third-party gems. It answers
# the three questions a patch-compliance dashboard actually cares about:
#
# 1. Are there uninstalled updates, and how severe are they (Critical /
# Important / Moderate / Low, per Microsoft's MSRC rating)?
# 2. Is the machine sitting on a pending reboot that's blocking updates
# already downloaded from taking effect?
# 3. How long has it been since the last hotfix was actually installed?
# (A quiet Windows Update service can look "compliant" while
# silently having stopped working weeks ago -- staleness catches
# that a green "0 pending updates" count would miss.)
#
# Because the Windows Update Agent COM API (Microsoft.Update.Session) and
# Win32_QuickFixEngineering only exist on Windows, the analysis logic is
# split from the collection logic: WmiCollector gathers a SystemSnapshot
# from the live machine (Windows only), and Analyzer classifies any
# SystemSnapshot -- live or loaded from a --fixture JSON file -- into
# OK/WARN/CRIT. That split is also a real feature, not just a test seam:
# `--export` lets a scheduled task on each Windows box drop a snapshot to
# a shared folder, and `--fixture` lets a central Linux/macOS box roll
# those snapshots up into one fleet-wide compliance report.
#
# Usage:
# ruby windows_update_audit.rb [--export FILE] (Windows, live)
# ruby windows_update_audit.rb --fixture FILE [--json] (any OS, offline)
#
require 'json'
require 'time'
require 'optparse'
# ---------------------------------------------------------------------------
# Plain data structures shared by the live collector and the fixture loader,
# so Analyzer never has to know or care where a snapshot came from.
# ---------------------------------------------------------------------------
PendingUpdate = Struct.new(:title, :kb_ids, :severity, :is_downloaded, keyword_init: true) do
def to_h_public
{ title: title, kb_ids: kb_ids, severity: severity, is_downloaded: is_downloaded }
end
end
Hotfix = Struct.new(:hotfix_id, :installed_on, :description, keyword_init: true)
SystemSnapshot = Struct.new(:hostname, :pending_updates, :hotfixes, :reboot_required,
:collected_at, keyword_init: true) do
def self.from_h(h)
new(
hostname: h['hostname'] || h[:hostname],
reboot_required: h['reboot_required'].nil? ? h[:reboot_required] : h['reboot_required'],
collected_at: (h['collected_at'] || h[:collected_at]),
pending_updates: (h['pending_updates'] || h[:pending_updates] || []).map do |u|
PendingUpdate.new(title: u['title'] || u[:title], kb_ids: u['kb_ids'] || u[:kb_ids],
severity: u['severity'] || u[:severity],
is_downloaded: u['is_downloaded'].nil? ? u[:is_downloaded] : u['is_downloaded'])
end,
hotfixes: (h['hotfixes'] || h[:hotfixes] || []).map do |hf|
Hotfix.new(hotfix_id: hf['hotfix_id'] || hf[:hotfix_id],
installed_on: hf['installed_on'] || hf[:installed_on],
description: hf['description'] || hf[:description])
end
)
end
def to_h_public
{
hostname: hostname,
collected_at: collected_at,
reboot_required: reboot_required,
pending_updates: pending_updates.map(&:to_h_public),
hotfixes: hotfixes.map(&:to_h)
}
end
end
# ---------------------------------------------------------------------------
# WmiCollector: talks to a live Windows machine via WIN32OLE. Only ever
# instantiated/called when running on Windows -- see the CLI section.
# ---------------------------------------------------------------------------
class WmiCollector
def collect
require 'win32ole'
session = WIN32OLE.new('Microsoft.Update.Session')
searcher = session.CreateUpdateSearcher
result = searcher.Search('IsInstalled=0 and IsHidden=0')
pending = []
result.Updates.each do |u|
kb_ids = []
u.KBArticleIDs.each { |kb| kb_ids << "KB#{kb}" }
pending << PendingUpdate.new(
title: u.Title,
kb_ids: kb_ids,
severity: u.MsrcSeverity.nil? || u.MsrcSeverity.empty? ? 'Unspecified' : u.MsrcSeverity,
is_downloaded: u.IsDownloaded
)
end
sysinfo = WIN32OLE.new('Microsoft.Update.SystemInfo')
reboot_required = sysinfo.RebootRequired
wmi = WIN32OLE.connect('winmgmts://./root/cimv2')
hotfixes = []
wmi.ExecQuery('SELECT HotFixID, InstalledOn, Description FROM Win32_QuickFixEngineering').each do |h|
hotfixes << Hotfix.new(hotfix_id: h.HotFixID, installed_on: h.InstalledOn, description: h.Description)
end
SystemSnapshot.new(
hostname: ENV['COMPUTERNAME'] || 'localhost',
pending_updates: pending,
hotfixes: hotfixes,
reboot_required: reboot_required,
collected_at: Time.now.utc.iso8601
)
end
end
# ---------------------------------------------------------------------------
# Analyzer: pure logic, no WMI/COM dependency, fully unit-testable on any
# platform against fixture snapshots. Classifies a SystemSnapshot into
# OK/WARN/CRIT with the specific reasons that drove the verdict.
# ---------------------------------------------------------------------------
class Analyzer
SEVERITY_ORDER = { 'Critical' => 3, 'Important' => 2, 'Moderate' => 1, 'Low' => 0, 'Unspecified' => 0 }.freeze
def initialize(warn_days: 45, crit_days: 90)
@warn_days = warn_days
@crit_days = crit_days
end
Verdict = Struct.new(:status, :reasons, :days_since_last_patch, keyword_init: true)
def classify(snapshot)
reasons = []
status = 'OK'
critical_pending = snapshot.pending_updates.select { |u| SEVERITY_ORDER[u.severity].to_i >= 3 }
important_pending = snapshot.pending_updates.select { |u| SEVERITY_ORDER[u.severity].to_i == 2 }
unless critical_pending.empty?
status = 'CRIT'
reasons << "#{critical_pending.size} Critical-severity update(s) pending: " \
"#{critical_pending.map { |u| u.kb_ids.join('/') }.join(', ')}"
end
unless important_pending.empty?
status = worse(status, 'WARN')
reasons << "#{important_pending.size} Important-severity update(s) pending"
end
if snapshot.reboot_required
status = worse(status, 'WARN')
reasons << 'reboot pending -- downloaded updates are not yet active'
end
days_since_last_patch = compute_days_since_last_patch(snapshot.hotfixes)
if days_since_last_patch
if days_since_last_patch >= @crit_days
status = 'CRIT'
reasons << "#{days_since_last_patch} days since the last installed hotfix (>= #{@crit_days}-day CRIT threshold)"
elsif days_since_last_patch >= @warn_days
status = worse(status, 'WARN')
reasons << "#{days_since_last_patch} days since the last installed hotfix (>= #{@warn_days}-day WARN threshold)"
end
else
status = worse(status, 'WARN')
reasons << 'no hotfix install history found -- cannot confirm patching is active'
end
reasons << 'no issues found' if reasons.empty?
Verdict.new(status: status, reasons: reasons, days_since_last_patch: days_since_last_patch)
end
private
def worse(current, candidate)
order = { 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }
order[candidate] > order[current] ? candidate : current
end
# Win32_QuickFixEngineering's InstalledOn comes back as a locale-dependent
# date string (e.g. "8/12/2026") rather than a WMI CIM_DATETIME, so we
# parse defensively and skip anything we can't confidently read rather
# than raising and aborting the whole audit over one bad row.
def compute_days_since_last_patch(hotfixes)
dates = hotfixes.filter_map { |h| parse_installed_on(h.installed_on) }
return nil if dates.empty?
(Date.today - dates.max).to_i
end
# Win32_QuickFixEngineering.InstalledOn is a locale-dependent string, not
# a CIM_DATETIME -- on a US-locale Windows box it comes back M/D/YYYY
# (e.g. "7/10/2026" for July 10th). Ruby's Date.parse is the wrong tool
# here: for slash-separated dates it guesses D/M/Y, so "7/10/2026" comes
# back as October 7th, silently 3 months off. We parse M/D/Y explicitly
# first (the common case) and only fall back to Date.parse for ISO-ish
# strings a --fixture file or a non-US locale might supply.
def parse_installed_on(raw)
return nil if raw.nil? || raw.to_s.strip.empty?
str = raw.to_s.strip
begin
Date.strptime(str, '%m/%d/%Y')
rescue ArgumentError, Date::Error
begin
Date.parse(str)
rescue ArgumentError, Date::Error
nil
end
end
end
end
require 'date'
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = { json: false, warn_days: 45, crit_days: 90 }
parser = OptionParser.new do |opts|
opts.banner = 'Usage: ruby windows_update_audit.rb [--fixture FILE] [--export FILE] [options]'
opts.on('--fixture FILE', 'Analyze a previously-exported JSON snapshot instead of live WMI') { |v| options[:fixture] = v }
opts.on('--export FILE', 'Also write the live-collected snapshot to FILE as JSON') { |v| options[:export] = v }
opts.on('--warn-days N', Integer, 'Days-since-last-patch WARN threshold (default: 45)') { |v| options[:warn_days] = v }
opts.on('--crit-days N', Integer, 'Days-since-last-patch CRIT threshold (default: 90)') { |v| options[:crit_days] = v }
opts.on('--json', 'Emit machine-readable JSON') { options[:json] = true }
end
parser.parse!(ARGV)
snapshot =
if options[:fixture]
SystemSnapshot.from_h(JSON.parse(File.read(options[:fixture])))
elsif RUBY_PLATFORM =~ /mingw|mswin|windows/i
snap = WmiCollector.new.collect
File.write(options[:export], JSON.pretty_generate(snap.to_h_public)) if options[:export]
snap
else
warn "error: live WMI collection requires Windows (RUBY_PLATFORM=#{RUBY_PLATFORM}). " \
'Pass --fixture FILE to analyze a snapshot collected elsewhere.'
exit 2
end
verdict = Analyzer.new(warn_days: options[:warn_days], crit_days: options[:crit_days]).classify(snapshot)
if options[:json]
puts JSON.pretty_generate(
hostname: snapshot.hostname,
status: verdict.status,
days_since_last_patch: verdict.days_since_last_patch,
reboot_required: snapshot.reboot_required,
pending_update_count: snapshot.pending_updates.size,
reasons: verdict.reasons
)
else
puts "host: #{snapshot.hostname} status: #{verdict.status}"
puts "pending updates: #{snapshot.pending_updates.size} reboot required: #{snapshot.reboot_required} " \
"days since last patch: #{verdict.days_since_last_patch || 'unknown'}"
puts '-' * 70
verdict.reasons.each { |r| puts " - #{r}" }
end
exit({ 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[verdict.status])
end
Step-by-step walkthrough
1. WmiCollector — the only Windows-specific code in the file
Microsoft.Update.Session and its CreateUpdateSearcher method are the same COM objects the Windows Update GUI and UsoClient use internally. The search filter "IsInstalled=0 and IsHidden=0" asks specifically for updates that are neither installed nor hidden by an admin — the same query PowerShell’s popular PSWindowsUpdate module runs. Each result’s MsrcSeverity gives Microsoft’s own Critical/Important/Moderate/Low rating, which is exactly what a compliance policy usually cares about, not just a raw count.
2. Reboot state and patch history
Microsoft.Update.SystemInfo#RebootRequired is a dedicated property for exactly this question — no registry spelunking needed. Win32_QuickFixEngineering then supplies the install history used to compute staleness; it’s worth noting in the code comments (and worth repeating here) that this WMI class only reports Component-Based-Servicing updates, not everything the Windows Update site has ever pushed, so treat “days since last patch” as a strong staleness signal rather than a complete audit trail.
3. Analyzer — classification with zero WMI dependency
This is the part that’s actually unit-tested (see windows_update_audit_test.rb in the GitHub folder): any Critical-severity pending update forces CRIT outright; Important-severity updates or a pending reboot each push the status to at least WARN; and patch staleness is evaluated independently against --warn-days / --crit-days thresholds. All the reasons that contributed to the final verdict are collected, not just the worst one, so the output tells you everything worth knowing, not just the headline status.
4. The locale date-parsing bug, and why it matters here specifically
This is genuinely the kind of bug that would ship quietly: Date.parse("7/10/2026") doesn’t raise, it just returns the wrong date (October 7th instead of July 10th), so a monitoring script using naive Date.parse here would report “10 days since last patch” when the real number is closer to 100 — a false sense of security that’s arguably worse than no check at all. Date.strptime(str, '%m/%d/%Y') pins the format explicitly instead of letting Ruby guess.
Example output
Because Microsoft.Update.Session and Win32_QuickFixEngineering only exist on Windows, this couldn’t be exercised end-to-end in the Linux sandbox this was developed in. Instead, the pure-logic Analyzer class was fully unit-tested (14 checks, shown above) against hand-built SystemSnapshot fixtures covering every branch: clean systems, Critical/Important pending updates, pending reboots, stale patching, missing history, and the locale date-parsing regression specifically. The CLI’s --fixture flag was also run end-to-end against five JSON fixture files (see test/fixtures/ on GitHub) to confirm the full command-line path, not just the class in isolation. The live WIN32OLE collection path (WmiCollector) has not been run against a real Windows host in this environment — it should be validated on an actual Windows box before relying on it in production.
Troubleshooting
WIN32OLERuntimeErrorcreatingMicrosoft.Update.Session: the Windows
Update Agent service may be disabled or the machine may be locked down by policy — confirm the “Windows
Update” service (wuauserv) is at least in a startable state.- Search takes a very long time on first run: this is normal — the Update Agent has to
sync its local update catalog, which can take a couple of minutes on a machine that hasn’t checked recently.
Subsequent runs are much faster. days since last patchlooks wrong / stuck at a number that never changes: remember
Win32_QuickFixEngineeringonly reflects Component-Based-Servicing updates — some update types
won’t appear there at all. Cross-check againstGet-HotFixin PowerShell if the number looks
suspicious.- Running on non-Windows raises immediately: that’s intentional — live collection requires
Windows. Use--fixtureto analyze a snapshot exported elsewhere. - Numbers don’t match the Windows Update GUI exactly: the GUI applies its own display filtering
(e.g. driver updates, optional updates) that can differ slightly from the rawIUpdateSearcherquery
this script uses — treat small discrepancies as expected, not a bug.
Extending this script
- Fleet rollup: have each Windows box run with
--export \\fileserver\snapshots\%COMPUTERNAME%.json
on a scheduled task, then run one Linux box nightly with--fixtureover every file in that folder to
build a single compliance report. - Auto-remediation: when
reboot_requiredis true and status is otherwise clean,
schedule an off-hours restart viashutdown /r /t 3600rather than just alerting. - Severity-weighted SLAs: track how long each individual Critical update has been pending (not
just whether one exists) to enforce “Critical patches must install within 7 days” style policies. - Combine with
eventlog-monitor: cross-reference this toolkit’s Windows Event Log
watcher for update-related failure events (Event ID 20/24/25 from the WindowsUpdateClient source) to distinguish
“nothing pending” from “updates are failing to install.”