the shed // windows // patch compliance

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_audit.rb

“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
Get the code

Full script + README on GitHub: ruby-devops-toolkit/windows-update-audit

architecture

How it fits together

Diagram: WmiCollector gathers a SystemSnapshot from a live Windows host via the Update Agent COM API and WMI, Analyzer classifies any snapshot -- live or from a --fixture JSON file -- into OK, WARN, or CRIT

Collect on Windows, analyze anywhere
prerequisites

Prerequisites

What you need
  • Ruby 3.0+ on Windows for live collection (uses the bundled win32ole library,
    which ships with Ruby on Windows — nothing extra to install).
  • Any OS for offline analysis via --fixture — the Analyzer class
    has zero Windows-specific dependencies.
  • No gems. win32ole, json, optparse, date,
    and time are 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.
reference

The full script

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

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.

output

Example output

windows_update_audit_test.rb — 14/14 passing
$ ruby windows_update_audit_test.rb
one Critical pending update -> CRIT regardless of anything else
ok – status is CRIT
ok – reason mentions Critical
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
14/14 checks passed
$ ruby windows_update_audit.rb –fixture test/fixtures/crit_critical_pending.json
host: DB03 status: CRIT
– 1 Critical-severity update(s) pending: KB5041500
exit=2

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

Troubleshooting

Common issues
  • WIN32OLERuntimeError creating Microsoft.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 patch looks wrong / stuck at a number that never changes: remember
    Win32_QuickFixEngineering only reflects Component-Based-Servicing updates — some update types
    won’t appear there at all. Cross-check against Get-HotFix in PowerShell if the number looks
    suspicious.
  • Running on non-Windows raises immediately: that’s intentional — live collection requires
    Windows. Use --fixture to 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 raw IUpdateSearcher query
    this script uses — treat small discrepancies as expected, not a bug.
extending

Extending this script

Ideas
  • Fleet rollup: have each Windows box run with --export \\fileserver\snapshots\%COMPUTERNAME%.json
    on a scheduled task, then run one Linux box nightly with --fixture over every file in that folder to
    build a single compliance report.
  • Auto-remediation: when reboot_required is true and status is otherwise clean,
    schedule an off-hours restart via shutdown /r /t 3600 rather 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.”