the shed // windows / backup

Ransomware deletes shadow copies before it encrypts anything, and Windows evicts old snapshots silently when shadow storage fills. This Ruby script queries WMI for every shadow copy and its storage cap, then tells you which volumes are actually protected.

Get the code

Full script + README on GitHub: ruby-devops-toolkit/win-vss-snapshot-audit

Step through the build below:

win_vss_snapshot_audit.rb

"We have Previous Versions" is not a backup strategy, but it is often the first restore point. Volume Shadow Copy snapshots are what System Restore, file-server Previous Versions, and most backup agents rely on. They fail in two quiet ways: storage exhaustion (the diff area hits its cap and the oldest snapshots vanish) and deliberate deletion (vssadmin delete shadows /all /quiet is in the playbook of nearly every ransomware family).

We want a script that lists every snapshot with its age, shows shadow storage usage against its maximum, lets you declare volumes that must have snapshots, and exits 0/1/2 so it can run as a scheduled task feeding a monitoring system.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_vss_snapshot_audit.rb - Windows Volume Shadow Copy (VSS) audit via WMI
#
# Answers the questions a sysadmin asks after a ransomware scare or a failed
# restore: "Which volumes actually have shadow copies? How old is the newest
# one? Is shadow storage about to hit its cap and silently evict the oldest
# snapshots? Did someone (or something) delete them all?"
#
# Queries Win32_ShadowCopy and Win32_ShadowStorage through WMI using the
# win32ole stdlib (no gems, no vssadmin parsing), then applies simple policy
# thresholds and exits with a monitoring-friendly code:
#
#   0 = OK        every protected volume has a fresh snapshot, storage healthy
#   1 = WARNING   newest snapshot older than --max-age, or storage > --warn-pct used
#   2 = CRITICAL  a --require volume has NO snapshots at all, or storage > --crit-pct
#
# Usage (Windows, elevated prompt recommended):
#   ruby win_vss_snapshot_audit.rb
#   ruby win_vss_snapshot_audit.rb --require C: --require D: --max-age 24
#   ruby win_vss_snapshot_audit.rb --json
#
# Test on any OS with the built-in fixture (no WMI needed):
#   ruby win_vss_snapshot_audit.rb --fixture
#
# Ruby >= 2.7 (RubyInstaller on Windows), stdlib only.
require 'json'
require 'optparse'
require 'time'
module VssAudit
  VERSION = '1.0.0'
  Snapshot = Struct.new(:id, :volume, :created_at, :provider, :persistent, :client_accessible, keyword_init: true)
  Storage  = Struct.new(:volume, :diff_volume, :used_bytes, :allocated_bytes, :max_bytes, keyword_init: true) do
    def pct_used
      return 0.0 if max_bytes.nil? || max_bytes.zero? || max_bytes == 0xFFFFFFFFFFFFFFFF # UNBOUNDED
      (used_bytes.to_f / max_bytes * 100).round(1)
    end
  end
  # ---- WMI source (real) ----------------------------------------------------
  class WmiSource
    def initialize
      require 'win32ole'
      @wmi = WIN32OLE.connect('winmgmts://./root/cimv2')
    end
    def snapshots
      volumes = volume_names # DeviceID -> "C:"
      @wmi.ExecQuery('SELECT ID, VolumeName, InstallDate, ProviderID, Persistent, ClientAccessible FROM Win32_ShadowCopy').map do |s|
        Snapshot.new(
          id: s.ID,
          volume: volumes[s.VolumeName] || s.VolumeName,
          created_at: parse_wmi_time(s.InstallDate),
          provider: s.ProviderID,
          persistent: s.Persistent,
          client_accessible: s.ClientAccessible
        )
      end
    end
    def storage
      volumes = volume_names
      @wmi.ExecQuery('SELECT Volume, DiffVolume, UsedSpace, AllocatedSpace, MaxSpace FROM Win32_ShadowStorage').map do |st|
        Storage.new(
          volume: volumes[ref_device_id(st.Volume)] || st.Volume,
          diff_volume: volumes[ref_device_id(st.DiffVolume)] || st.DiffVolume,
          used_bytes: st.UsedSpace.to_i,
          allocated_bytes: st.AllocatedSpace.to_i,
          max_bytes: st.MaxSpace.to_i
        )
      end
    end
    private
    # Win32_Volume maps the ugly \\?\Volume{GUID}\ DeviceID to a drive letter.
    def volume_names
      @volume_names ||= @wmi.ExecQuery('SELECT DeviceID, DriveLetter FROM Win32_Volume')
                            .each_with_object({}) { |v, h| h[v.DeviceID] = v.DriveLetter || v.DeviceID }
    end
    # Win32_ShadowStorage.Volume is an object reference string like
    #   \\HOST\root\cimv2:Win32_Volume.DeviceID="\\\\?\\Volume{...}\\"
    def ref_device_id(ref)
      ref.to_s[/DeviceID="(.+)"\z/, 1].to_s.gsub('\\\\', '\\')
    end
    # WMI datetime: 20260909031500.000000-000  (CIM_DATETIME)
    def parse_wmi_time(s)
      return nil if s.nil? || s.empty?
      y, mo, d, h, mi, sec = s[0, 4], s[4, 2], s[6, 2], s[8, 2], s[10, 2], s[12, 2]
      offset_min = s[-4..].to_i * (s[-4 - 1] == '-' ? -1 : 1)
      Time.new(y.to_i, mo.to_i, d.to_i, h.to_i, mi.to_i, sec.to_i, offset_min * 60)
    end
  end
  # ---- Fixture source (for tests / non-Windows) -----------------------------
  class FixtureSource
    def initialize(now: Time.now)
      @now = now
    end
    def snapshots
      [
        Snapshot.new(id: '{a1}', volume: 'C:', created_at: @now - 3 * 3600,  provider: '{b5946137-7b9f-4925-af80-51abd60b20d5}', persistent: true, client_accessible: true),
        Snapshot.new(id: '{a2}', volume: 'C:', created_at: @now - 27 * 3600, provider: '{b5946137-7b9f-4925-af80-51abd60b20d5}', persistent: true, client_accessible: true),
        Snapshot.new(id: '{a3}', volume: 'D:', created_at: @now - 40 * 3600, provider: '{b5946137-7b9f-4925-af80-51abd60b20d5}', persistent: true, client_accessible: true)
      ]
    end
    def storage
      gib = 1024**3
      [
        Storage.new(volume: 'C:', diff_volume: 'C:', used_bytes: 18 * gib, allocated_bytes: 19 * gib, max_bytes: 20 * gib),
        Storage.new(volume: 'D:', diff_volume: 'D:', used_bytes: 4 * gib,  allocated_bytes: 5 * gib,  max_bytes: 50 * gib)
      ]
    end
  end
  # ---- Policy -----------------------------------------------------------------
  class Auditor
    def initialize(require_volumes:, max_age_h:, warn_pct:, crit_pct:, now: Time.now)
      @require = require_volumes.map(&:upcase)
      @max_age_h = max_age_h
      @warn_pct = warn_pct
      @crit_pct = crit_pct
      @now = now
    end
    def audit(snapshots, storage)
      findings = []
      by_vol = snapshots.group_by { |s| s.volume.to_s.upcase }
      @require.each do |vol|
        findings << [2, "#{vol} has NO shadow copies"] unless by_vol.key?(vol)
      end
      by_vol.each do |vol, snaps|
        newest = snaps.map(&:created_at).compact.max
        next unless newest
        age_h = ((@now - newest) / 3600).round(1)
        findings << [1, "#{vol} newest snapshot is #{age_h}h old (limit #{@max_age_h}h)"] if age_h > @max_age_h
      end
      storage.each do |st|
        pct = st.pct_used
        if pct >= @crit_pct
          findings << [2, "#{st.volume} shadow storage #{pct}% used (crit #{@crit_pct}%)"]
        elsif pct >= @warn_pct
          findings << [1, "#{st.volume} shadow storage #{pct}% used (warn #{@warn_pct}%)"]
        end
      end
      code = findings.map(&:first).max || 0
      [code, findings]
    end
  end
  # ---- Output -----------------------------------------------------------------
  module Report
    LABEL = %w[OK WARNING CRITICAL].freeze
    def self.human(bytes)
      return 'unbounded' if bytes.nil? || bytes == 0xFFFFFFFFFFFFFFFF
      units = %w[B KiB MiB GiB TiB]
      i = 0
      f = bytes.to_f
      while f >= 1024 && i < units.size - 1
        f /= 1024
        i += 1
      end
      format('%.1f %s', f, units[i])
    end
    def self.text(code, findings, snapshots, storage, now)
      out = ["#{LABEL[code]} - #{findings.empty? ? 'all volumes protected' : findings.map(&:last).join('; ')}", '']
      out << 'SHADOW COPIES'
      snapshots.sort_by { |s| [s.volume.to_s, -s.created_at.to_i] }.each do |s|
        age = ((now - s.created_at) / 3600).round(1)
        out << format('  %-4s %-19s age %6.1fh  %s%s', s.volume, s.created_at.strftime('%Y-%m-%d %H:%M:%S'), age,
                      s.persistent ? 'persistent' : 'temp', s.client_accessible ? ', client-accessible' : '')
      end
      out << '' << 'SHADOW STORAGE'
      storage.each do |st|
        out << format('  %-4s on %-4s used %-10s alloc %-10s max %-10s (%s%%)', st.volume, st.diff_volume,
                      human(st.used_bytes), human(st.allocated_bytes), human(st.max_bytes), st.pct_used)
      end
      out.join("\n")
    end
    def self.json(code, findings, snapshots, storage)
      JSON.pretty_generate(
        status: LABEL[code],
        findings: findings.map { |sev, msg| { severity: LABEL[sev], message: msg } },
        snapshots: snapshots.map { |s| s.to_h.merge(created_at: s.created_at&.iso8601) },
        storage: storage.map { |st| st.to_h.merge(pct_used: st.pct_used) }
      )
    end
  end
  def self.run(argv)
    opts = { require: [], max_age: 24.0, warn: 80.0, crit: 95.0, json: false, fixture: false }
    OptionParser.new do |o|
      o.banner = 'Usage: win_vss_snapshot_audit.rb [--require C:]... [--max-age HOURS] [--warn-pct N] [--crit-pct N] [--json] [--fixture]'
      o.on('--require VOL', 'Volume that MUST have at least one snapshot (repeatable)') { |v| opts[:require] << v }
      o.on('--max-age HOURS', Float) { |v| opts[:max_age] = v }
      o.on('--warn-pct N', Float) { |v| opts[:warn] = v }
      o.on('--crit-pct N', Float) { |v| opts[:crit] = v }
      o.on('--json') { opts[:json] = true }
      o.on('--fixture', 'Use built-in sample data instead of WMI (for testing)') { opts[:fixture] = true }
      o.on('-v', '--version') { puts VERSION; exit }
    end.parse!(argv)
    now = Time.now
    source = if opts[:fixture]
               FixtureSource.new(now: now)
             elsif Gem.win_platform?
               WmiSource.new
             else
               warn 'error: WMI is only available on Windows. Use --fixture to test elsewhere.'
               exit 3
             end
    snapshots = source.snapshots
    storage   = source.storage
    code, findings = Auditor.new(require_volumes: opts[:require], max_age_h: opts[:max_age],
                                 warn_pct: opts[:warn], crit_pct: opts[:crit], now: now)
                            .audit(snapshots, storage)
    puts(opts[:json] ? Report.json(code, findings, snapshots, storage) : Report.text(code, findings, snapshots, storage, now))
    exit code
  rescue StandardError => e
    warn "error: #{e.class}: #{e.message}"
    exit 3
  end
end
VssAudit.run(ARGV) if $PROGRAM_NAME == __FILE__

WmiSource uses the win32ole stdlib to connect to winmgmts://./root/cimv2 and run three WQL queries. Win32_ShadowCopy gives snapshots but names volumes by \\?\Volume{GUID}\; Win32_Volume maps those GUIDs to drive letters. Win32_ShadowStorage returns object-reference strings for its volumes, which ref_device_id unpicks.

CIM_DATETIME strings like 20260909031500.000000-300 are parsed by hand: the last four characters are a UTC offset in minutes. Getting this wrong makes every snapshot look hours older or younger than it is.

Auditor is pure policy: a --require volume with no snapshots is CRITICAL; newest snapshot older than --max-age is WARNING; storage above --warn-pct/--crit-pct escalates. FixtureSource returns canned data so the whole pipeline runs on Linux or in CI, and a separate stub harness fakes WIN32OLE to exercise the real WmiSource parsing code.

$ captured from the sandbox test run

$ ruby win_vss_snapshot_audit.rb --fixture --require C: --require E:
CRITICAL - E: has NO shadow copies; D: newest snapshot is 40.0h old (limit 24.0h); C: shadow storage 90.0% used (warn 80.0%)
SHADOW COPIES
  C:   2026-09-09 07:39:40 age    3.0h  persistent, client-accessible
  C:   2026-09-08 07:39:40 age   27.0h  persistent, client-accessible
  D:   2026-09-07 18:39:40 age   40.0h  persistent, client-accessible
SHADOW STORAGE
  C:   on C:   used 18.0 GiB   alloc 19.0 GiB   max 20.0 GiB   (90.0%)
  D:   on D:   used 4.0 GiB    alloc 5.0 GiB    max 50.0 GiB   (8.0%)
exit=2
$ ruby win_vss_snapshot_audit.rb --fixture --max-age 48 --warn-pct 95
OK - all volumes protected
SHADOW COPIES
  C:   2026-09-09 07:39:40 age    3.0h  persistent, client-accessible
  C:   2026-09-08 07:39:40 age   27.0h  persistent, client-accessible
  D:   2026-09-07 18:39:40 age   40.0h  persistent, client-accessible
SHADOW STORAGE
  C:   on C:   used 18.0 GiB   alloc 19.0 GiB   max 20.0 GiB   (90.0%)
  D:   on D:   used 4.0 GiB    alloc 5.0 GiB    max 50.0 GiB   (8.0%)
exit=0
$ ruby win_vss_snapshot_audit.rb   # on Linux, no --fixture
error: WMI is only available on Windows. Use --fixture to test elsewhere.
exit=3
$ ruby test_wmi_stub.rb   # mocks WIN32OLE, drives the real WmiSource code path
snapshot: C: created 2026-09-09T03:15:00-05:00
storage : C: diff on D: used=1.0 GiB max=unbounded pct=0.0
STUB HARNESS: all assertions passed
exit=0
WMI classes to Source to Auditor to exit code

win32ole -> root\cimv2 -> Win32_ShadowCopy / Win32_ShadowStorage / Win32_Volume -> verdict
01 / context

The real-world problem

Shadow copies are created by scheduled tasks, backup agents, and Windows Update, and consumed by Previous Versions, System Restore, and every VSS-aware backup product. Because they are created and expired automatically, nobody notices when they stop existing. The classic failure story: a file server has a 10% shadow storage cap, a large data migration churns the diff area, all snapshots older than a day are evicted, and the next accidental deletion has nothing to restore from.

vssadmin list shadows shows the data but its output is localised prose that is painful to parse. WMI exposes the same objects as typed properties, and Ruby’s win32ole makes calling WMI a few lines with no gems. The script normalises the results into plain Ruby structs so the policy logic and reporting never see a COM object.

The verdict is deliberately monitoring-shaped: one summary line, an exit code, and --json for anything that wants structure.

02 / setup

Prerequisites

You need
  • Windows 10/11 or Windows Server 2016+ with the Volume Shadow Copy service (VSS) running. Run from an elevated prompt; non-admin queries of Win32_ShadowCopy return empty results on many builds.
  • Ruby 2.7+ via RubyInstaller; win32ole ships with it. Stdlib only otherwise: json, optparse, time.
  • Any OS for testing: --fixture runs the full pipeline on sample data, which is how the output tab was captured on Linux.
  • Optional: a scheduled task (schtasks /create) and a monitoring agent that reads exit codes.
03 / source

The complete script

Everything below is the exact file that ran in the output tab. It is stdlib-only, so there is no Gemfile to install.

win_vss_snapshot_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_vss_snapshot_audit.rb - Windows Volume Shadow Copy (VSS) audit via WMI
#
# Answers the questions a sysadmin asks after a ransomware scare or a failed
# restore: "Which volumes actually have shadow copies? How old is the newest
# one? Is shadow storage about to hit its cap and silently evict the oldest
# snapshots? Did someone (or something) delete them all?"
#
# Queries Win32_ShadowCopy and Win32_ShadowStorage through WMI using the
# win32ole stdlib (no gems, no vssadmin parsing), then applies simple policy
# thresholds and exits with a monitoring-friendly code:
#
#   0 = OK        every protected volume has a fresh snapshot, storage healthy
#   1 = WARNING   newest snapshot older than --max-age, or storage > --warn-pct used
#   2 = CRITICAL  a --require volume has NO snapshots at all, or storage > --crit-pct
#
# Usage (Windows, elevated prompt recommended):
#   ruby win_vss_snapshot_audit.rb
#   ruby win_vss_snapshot_audit.rb --require C: --require D: --max-age 24
#   ruby win_vss_snapshot_audit.rb --json
#
# Test on any OS with the built-in fixture (no WMI needed):
#   ruby win_vss_snapshot_audit.rb --fixture
#
# Ruby >= 2.7 (RubyInstaller on Windows), stdlib only.
require 'json'
require 'optparse'
require 'time'
module VssAudit
  VERSION = '1.0.0'
  Snapshot = Struct.new(:id, :volume, :created_at, :provider, :persistent, :client_accessible, keyword_init: true)
  Storage  = Struct.new(:volume, :diff_volume, :used_bytes, :allocated_bytes, :max_bytes, keyword_init: true) do
    def pct_used
      return 0.0 if max_bytes.nil? || max_bytes.zero? || max_bytes == 0xFFFFFFFFFFFFFFFF # UNBOUNDED
      (used_bytes.to_f / max_bytes * 100).round(1)
    end
  end
  # ---- WMI source (real) ----------------------------------------------------
  class WmiSource
    def initialize
      require 'win32ole'
      @wmi = WIN32OLE.connect('winmgmts://./root/cimv2')
    end
    def snapshots
      volumes = volume_names # DeviceID -> "C:"
      @wmi.ExecQuery('SELECT ID, VolumeName, InstallDate, ProviderID, Persistent, ClientAccessible FROM Win32_ShadowCopy').map do |s|
        Snapshot.new(
          id: s.ID,
          volume: volumes[s.VolumeName] || s.VolumeName,
          created_at: parse_wmi_time(s.InstallDate),
          provider: s.ProviderID,
          persistent: s.Persistent,
          client_accessible: s.ClientAccessible
        )
      end
    end
    def storage
      volumes = volume_names
      @wmi.ExecQuery('SELECT Volume, DiffVolume, UsedSpace, AllocatedSpace, MaxSpace FROM Win32_ShadowStorage').map do |st|
        Storage.new(
          volume: volumes[ref_device_id(st.Volume)] || st.Volume,
          diff_volume: volumes[ref_device_id(st.DiffVolume)] || st.DiffVolume,
          used_bytes: st.UsedSpace.to_i,
          allocated_bytes: st.AllocatedSpace.to_i,
          max_bytes: st.MaxSpace.to_i
        )
      end
    end
    private
    # Win32_Volume maps the ugly \\?\Volume{GUID}\ DeviceID to a drive letter.
    def volume_names
      @volume_names ||= @wmi.ExecQuery('SELECT DeviceID, DriveLetter FROM Win32_Volume')
                            .each_with_object({}) { |v, h| h[v.DeviceID] = v.DriveLetter || v.DeviceID }
    end
    # Win32_ShadowStorage.Volume is an object reference string like
    #   \\HOST\root\cimv2:Win32_Volume.DeviceID="\\\\?\\Volume{...}\\"
    def ref_device_id(ref)
      ref.to_s[/DeviceID="(.+)"\z/, 1].to_s.gsub('\\\\', '\\')
    end
    # WMI datetime: 20260909031500.000000-000  (CIM_DATETIME)
    def parse_wmi_time(s)
      return nil if s.nil? || s.empty?
      y, mo, d, h, mi, sec = s[0, 4], s[4, 2], s[6, 2], s[8, 2], s[10, 2], s[12, 2]
      offset_min = s[-4..].to_i * (s[-4 - 1] == '-' ? -1 : 1)
      Time.new(y.to_i, mo.to_i, d.to_i, h.to_i, mi.to_i, sec.to_i, offset_min * 60)
    end
  end
  # ---- Fixture source (for tests / non-Windows) -----------------------------
  class FixtureSource
    def initialize(now: Time.now)
      @now = now
    end
    def snapshots
      [
        Snapshot.new(id: '{a1}', volume: 'C:', created_at: @now - 3 * 3600,  provider: '{b5946137-7b9f-4925-af80-51abd60b20d5}', persistent: true, client_accessible: true),
        Snapshot.new(id: '{a2}', volume: 'C:', created_at: @now - 27 * 3600, provider: '{b5946137-7b9f-4925-af80-51abd60b20d5}', persistent: true, client_accessible: true),
        Snapshot.new(id: '{a3}', volume: 'D:', created_at: @now - 40 * 3600, provider: '{b5946137-7b9f-4925-af80-51abd60b20d5}', persistent: true, client_accessible: true)
      ]
    end
    def storage
      gib = 1024**3
      [
        Storage.new(volume: 'C:', diff_volume: 'C:', used_bytes: 18 * gib, allocated_bytes: 19 * gib, max_bytes: 20 * gib),
        Storage.new(volume: 'D:', diff_volume: 'D:', used_bytes: 4 * gib,  allocated_bytes: 5 * gib,  max_bytes: 50 * gib)
      ]
    end
  end
  # ---- Policy -----------------------------------------------------------------
  class Auditor
    def initialize(require_volumes:, max_age_h:, warn_pct:, crit_pct:, now: Time.now)
      @require = require_volumes.map(&:upcase)
      @max_age_h = max_age_h
      @warn_pct = warn_pct
      @crit_pct = crit_pct
      @now = now
    end
    def audit(snapshots, storage)
      findings = []
      by_vol = snapshots.group_by { |s| s.volume.to_s.upcase }
      @require.each do |vol|
        findings << [2, "#{vol} has NO shadow copies"] unless by_vol.key?(vol)
      end
      by_vol.each do |vol, snaps|
        newest = snaps.map(&:created_at).compact.max
        next unless newest
        age_h = ((@now - newest) / 3600).round(1)
        findings << [1, "#{vol} newest snapshot is #{age_h}h old (limit #{@max_age_h}h)"] if age_h > @max_age_h
      end
      storage.each do |st|
        pct = st.pct_used
        if pct >= @crit_pct
          findings << [2, "#{st.volume} shadow storage #{pct}% used (crit #{@crit_pct}%)"]
        elsif pct >= @warn_pct
          findings << [1, "#{st.volume} shadow storage #{pct}% used (warn #{@warn_pct}%)"]
        end
      end
      code = findings.map(&:first).max || 0
      [code, findings]
    end
  end
  # ---- Output -----------------------------------------------------------------
  module Report
    LABEL = %w[OK WARNING CRITICAL].freeze
    def self.human(bytes)
      return 'unbounded' if bytes.nil? || bytes == 0xFFFFFFFFFFFFFFFF
      units = %w[B KiB MiB GiB TiB]
      i = 0
      f = bytes.to_f
      while f >= 1024 && i < units.size - 1
        f /= 1024
        i += 1
      end
      format('%.1f %s', f, units[i])
    end
    def self.text(code, findings, snapshots, storage, now)
      out = ["#{LABEL[code]} - #{findings.empty? ? 'all volumes protected' : findings.map(&:last).join('; ')}", '']
      out << 'SHADOW COPIES'
      snapshots.sort_by { |s| [s.volume.to_s, -s.created_at.to_i] }.each do |s|
        age = ((now - s.created_at) / 3600).round(1)
        out << format('  %-4s %-19s age %6.1fh  %s%s', s.volume, s.created_at.strftime('%Y-%m-%d %H:%M:%S'), age,
                      s.persistent ? 'persistent' : 'temp', s.client_accessible ? ', client-accessible' : '')
      end
      out << '' << 'SHADOW STORAGE'
      storage.each do |st|
        out << format('  %-4s on %-4s used %-10s alloc %-10s max %-10s (%s%%)', st.volume, st.diff_volume,
                      human(st.used_bytes), human(st.allocated_bytes), human(st.max_bytes), st.pct_used)
      end
      out.join("\n")
    end
    def self.json(code, findings, snapshots, storage)
      JSON.pretty_generate(
        status: LABEL[code],
        findings: findings.map { |sev, msg| { severity: LABEL[sev], message: msg } },
        snapshots: snapshots.map { |s| s.to_h.merge(created_at: s.created_at&.iso8601) },
        storage: storage.map { |st| st.to_h.merge(pct_used: st.pct_used) }
      )
    end
  end
  def self.run(argv)
    opts = { require: [], max_age: 24.0, warn: 80.0, crit: 95.0, json: false, fixture: false }
    OptionParser.new do |o|
      o.banner = 'Usage: win_vss_snapshot_audit.rb [--require C:]... [--max-age HOURS] [--warn-pct N] [--crit-pct N] [--json] [--fixture]'
      o.on('--require VOL', 'Volume that MUST have at least one snapshot (repeatable)') { |v| opts[:require] << v }
      o.on('--max-age HOURS', Float) { |v| opts[:max_age] = v }
      o.on('--warn-pct N', Float) { |v| opts[:warn] = v }
      o.on('--crit-pct N', Float) { |v| opts[:crit] = v }
      o.on('--json') { opts[:json] = true }
      o.on('--fixture', 'Use built-in sample data instead of WMI (for testing)') { opts[:fixture] = true }
      o.on('-v', '--version') { puts VERSION; exit }
    end.parse!(argv)
    now = Time.now
    source = if opts[:fixture]
               FixtureSource.new(now: now)
             elsif Gem.win_platform?
               WmiSource.new
             else
               warn 'error: WMI is only available on Windows. Use --fixture to test elsewhere.'
               exit 3
             end
    snapshots = source.snapshots
    storage   = source.storage
    code, findings = Auditor.new(require_volumes: opts[:require], max_age_h: opts[:max_age],
                                 warn_pct: opts[:warn], crit_pct: opts[:crit], now: now)
                            .audit(snapshots, storage)
    puts(opts[:json] ? Report.json(code, findings, snapshots, storage) : Report.text(code, findings, snapshots, storage, now))
    exit code
  rescue StandardError => e
    warn "error: #{e.class}: #{e.message}"
    exit 3
  end
end
VssAudit.run(ARGV) if $PROGRAM_NAME == __FILE__
04 / walkthrough

How the code works, step by step

1. Choose a source

run picks FixtureSource with --fixture, WmiSource on Windows, and otherwise exits 3 with a clear message. Both sources expose the same two methods, snapshots and storage, so everything downstream is source-agnostic.

2. Query WMI

WIN32OLE.connect('winmgmts://./root/cimv2') attaches to the local WMI namespace. ExecQuery returns an enumerable of COM objects whose properties are read like Ruby attributes: s.InstallDate, st.MaxSpace. Numeric WMI values of type uint64 arrive as strings, hence the .to_i calls.

3. Normalise volume names

volume_names builds a DeviceID -> DriveLetter hash once and memoises it. Win32_ShadowStorage.Volume is not a plain string but a reference like \\HOST\root\cimv2:Win32_Volume.DeviceID="\\\\?\\Volume{...}\\"; ref_device_id extracts and un-escapes the DeviceID so it matches the hash key.

4. Parse CIM_DATETIME

parse_wmi_time slices the fixed-width fields and applies the trailing UTC offset (minutes, signed). The stub harness asserts that 03:15 -300 becomes 08:15Z.

5. Apply policy and report

Auditor#audit collects [severity, message] findings and returns the max severity as the exit code. Storage#pct_used treats MaxSpace = 0xFFFFFFFFFFFFFFFF as unbounded (what vssadmin resize shadowstorage /maxsize=UNBOUNDED sets) so it never reports a false 100%.

05 / output

Example output

Against the fixture (a C: with two snapshots and 90% shadow storage, a D: whose newest snapshot is 40h old, and a required E: with none), plus the WMI stub harness:

ruby win_vss_snapshot_audit.rb –fixture –require C: –require E:
CRITICAL – E: has NO shadow copies; D: newest snapshot is 40.0h old (limit 24.0h); C: shadow storage 90.0% used (warn 80.0%)
SHADOW COPIES
C: 2026-09-09 07:39:25 age 3.0h persistent, client-accessible
C: 2026-09-08 07:39:25 age 27.0h persistent, client-accessible
D: 2026-09-07 18:39:25 age 40.0h persistent, client-accessible
SHADOW STORAGE
C: on C: used 18.0 GiB alloc 19.0 GiB max 20.0 GiB (90.0%)
D: on D: used 4.0 GiB alloc 5.0 GiB max 50.0 GiB (8.0%)
exit=2
$ ruby test_wmi_stub.rb
snapshot: C: created 2026-09-09T03:15:00-05:00
storage : C: diff on D: used=1.0 GiB max=unbounded pct=0.0
STUB HARNESS: all assertions passed
06 / debug

Troubleshooting

When it misbehaves
  • Honest note on testing: this sandbox is Linux, so WmiSource could not talk to a real WMI service. The policy, reporting and CLI were verified with --fixture, and the WMI parsing code (volume mapping, object-reference unpacking, CIM_DATETIME) was driven by a mock WIN32OLE class in the README’s stub harness. Run it once on a real Windows host before trusting it in production.
  • Empty snapshot list but vssadmin list shadows shows some: you are not elevated. Open the prompt as Administrator.
  • WIN32OLERuntimeError: Access denied / RPC server unavailable: the WMI service (winmgmt) is stopped or the repository is corrupt. winmgmt /verifyrepository then winmgmt /salvagerepository.
  • Storage shows 0% with max ‘unbounded’: that is correct for volumes configured with /maxsize=UNBOUNDED; the script only percent-checks capped volumes.
  • Volumes appear as \\?\Volume{...}: the volume has no drive letter (mount-point folder or system reserved). The script falls back to the DeviceID rather than dropping the row.
  • Snapshot ages are off by hours: check the machine’s timezone; InstallDate is local time with an explicit offset and the script honours it, but a host with a wrong clock will still report wrong ages.
07 / next

Extending the script

Ideas
  • Add --create VOL that calls Win32_ShadowCopy.Create("C:\", "ClientAccessible") when a required volume has none, turning the audit into a self-healing task.
  • Run it against remote hosts by changing the moniker to winmgmts://SERVER/root/cimv2 (requires WMI/DCOM firewall rules) and loop over a fleet list.
  • Export the JSON to the toolkit’s eventlog-monitor style Event Log entry so SIEM rules can alert on all snapshots gone.
  • Correlate with Win32_ShadowProvider to flag third-party providers that may bypass Windows’ own retention.
  • Track storage pct_used over time to predict when a cap will start evicting snapshots.