Ruby for DevOps: Auditing and Fixing Windows Registry Drift with Ruby

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

The Problem

Windows fleets drift. Group Policy covers a lot of ground, but plenty of shops still have registry-based configuration that gets set by hand during incident response, by an installer that doesn’t get re-run consistently, or by a script someone wrote three years ago that only ran once. Six months later nobody can say with confidence which of your two hundred servers actually have the security-hardening keys applied, which have the old logging level left over from a debugging session, or which are missing the agent heartbeat setting entirely.

This tutorial builds registry_audit.rb: a Ruby tool that reads a YAML “baseline” describing the registry values a host is supposed to have, compares it against reality, and reports exactly what’s out of compliance. With --fix it will also repair drift — but only after writing a timestamped JSON backup of every value it’s about to touch, so a bad baseline (or a bad day) can be undone.

Diagram of the registry_audit.rb baseline comparison and guarded fix workflow

Prerequisites

  • Ruby 2.7+ on Windows — the RubyInstaller builds ship win32/registry in the standard library, so there’s nothing to gem install for the registry access itself
  • Administrator privileges if you’re auditing/fixing HKEY_LOCAL_MACHINE values (standard user rights are enough for HKEY_CURRENT_USER)
  • Familiarity with the registry value types you’re targeting (REG_SZ, REG_DWORD, etc.)

A note on how this was tested: the actual registry I/O in this script uses Windows-only APIs (win32/registry) that simply don’t exist outside Windows, so I couldn’t execute that code path directly in the Linux sandbox this tutorial was written in. To keep the logic honest rather than just hoping it’s right, the script is built around a small backend interface — a “real” backend that talks to Win32::Registry, and a “fake” in-memory backend with the identical interface. All of the actual decision-making (drift detection, backup generation, the fix workflow, exit codes) is backend-agnostic and was fully exercised and verified against the fake backend. The Windows-specific code was written carefully against the documented win32/registry API and passed a Ruby syntax check, but treat it as reviewed-not-executed and confirm it against a real key on a test VM before trusting it in production.

The Complete Script

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# registry_audit.rb - Configuration-drift auditor for the Windows Registry, in Ruby.
#
# Reads a YAML "baseline" describing the registry values a fleet of Windows
# hosts is supposed to have (security policy, agent config, feature flags,
# etc.), compares it against what's actually on the machine, and reports
# MATCH / DRIFT / MISSING for each rule. With --fix it will also repair
# drifted or missing values -- but only after writing a timestamped JSON
# backup of every key it's about to touch, so a bad baseline can be undone.
#
# Usage (on Windows, with a real Ruby install such as RubyInstaller):
#   ruby registry_audit.rb --baseline baseline.yml
#   ruby registry_audit.rb --baseline baseline.yml --fix --backup-dir C:\registry_backups
#
# Usage (anywhere, including Linux/macOS, for testing the audit logic itself
# without touching a real registry):
#   ruby registry_audit.rb --baseline baseline.yml --backend fake --seed seed.json
#
# Ruby >= 2.7. On Windows, win32/registry ships with Ruby's standard library
# (RubyInstaller builds) -- no gems to install.

require 'yaml'
require 'json'
require 'optparse'
require 'time'
require 'fileutils'

# The auditor talks to "a registry" through this narrow interface:
#   read(hive, subkey, value_name)                    -> value or nil
#   write(hive, subkey, value_name, value, type)       -> void
#
# That indirection means the drift-detection logic below can be fully unit
# tested on any OS using the Fake backend, while WindowsBackend is the thing
# that actually talks to Win32::Registry when run on a Windows host.
module RegistryBackend
  # Talks to the real Windows registry. Only usable on Windows; the require
  # is done lazily here (not at file load time) so this file can still be
  # loaded, and its other classes exercised, on non-Windows systems.
  class Windows
    HIVES = {
      'HKEY_LOCAL_MACHINE' => :HKEY_LOCAL_MACHINE,
      'HKEY_CURRENT_USER' => :HKEY_CURRENT_USER,
      'HKEY_USERS' => :HKEY_USERS
    }.freeze

    TYPES = {
      'REG_SZ' => :REG_SZ,
      'REG_EXPAND_SZ' => :REG_EXPAND_SZ,
      'REG_DWORD' => :REG_DWORD,
      'REG_QWORD' => :REG_QWORD,
      'REG_MULTI_SZ' => :REG_MULTI_SZ
    }.freeze

    def initialize
      require 'win32/registry'
    end

    def read(hive, subkey, value_name)
      hive_const = Win32::Registry.const_get(HIVES.fetch(hive))
      hive_const.open(subkey, Win32::Registry::KEY_READ) do |reg|
        reg[value_name]
      end
    rescue Win32::Registry::Error
      nil # key or value doesn't exist -- treated as "missing" by the auditor
    end

    def write(hive, subkey, value_name, value, type)
      hive_const = Win32::Registry.const_get(HIVES.fetch(hive))
      type_const = Win32::Registry.const_get(TYPES.fetch(type))
      hive_const.create(subkey, Win32::Registry::KEY_ALL_ACCESS) do |reg|
        reg.write(value_name, type_const, value)
      end
    end
  end

  # In-memory stand-in for the registry. Used for the test suite, and also
  # handy for demoing/dry-running the auditor's reporting on a non-Windows
  # box before pointing it at production. Seed it from a JSON snapshot:
  #   { "HKEY_LOCAL_MACHINE": { "SOFTWARE\\Foo": { "Bar": "baz" } } }
  class Fake
    def initialize(seed = {})
      @store = deep_dup(seed)
    end

    def read(hive, subkey, value_name)
      @store.dig(hive, subkey, value_name)
    end

    def write(hive, subkey, value_name, value, _type)
      (@store[hive] ||= {})
      (@store[hive][subkey] ||= {})
      @store[hive][subkey][value_name] = value
    end

    private

    def deep_dup(obj)
      JSON.parse(JSON.generate(obj))
    end
  end
end

# One expected registry value, loaded from the baseline YAML file.
Rule = Struct.new(:hive, :subkey, :name, :type, :expected, keyword_init: true) do
  def self.from_hash(h)
    new(
      hive: h.fetch('hive'),
      subkey: h.fetch('subkey'),
      name: h.fetch('name'),
      type: h.fetch('type'),
      expected: h.fetch('expected')
    )
  end

  def label
    "#{hive}\\#{subkey}\\#{name}"
  end
end

# Result of checking one rule against the live backend.
CheckResult = Struct.new(:rule, :status, :actual, keyword_init: true)

class Auditor
  STATUSES = %i[match drift missing].freeze

  def initialize(backend:, rules:)
    @backend = backend
    @rules = rules
  end

  def audit
    @rules.map do |rule|
      actual = @backend.read(rule.hive, rule.subkey, rule.name)
      status =
        if actual.nil?
          :missing
        elsif actual == rule.expected
          :match
        else
          :drift
        end
      CheckResult.new(rule: rule, status: status, actual: actual)
    end
  end

  # Repairs every non-matching result by writing the expected value, after
  # recording a backup entry (rule + previous actual value) for each one.
  # Returns the list of backup entries so the caller can persist them.
  def fix(results)
    backups = []
    results.each do |result|
      next if result.status == :match

      backups << {
        'hive' => result.rule.hive,
        'subkey' => result.rule.subkey,
        'name' => result.rule.name,
        'previous_value' => result.actual,
        'previous_status' => result.status.to_s
      }
      @backend.write(result.rule.hive, result.rule.subkey, result.rule.name, result.rule.expected, result.rule.type)
    end
    backups
  end
end

def load_rules(path)
  YAML.safe_load(File.read(path)).map { |h| Rule.from_hash(h) }
end

def build_backend(options)
  case options[:backend]
  when 'windows'
    RegistryBackend::Windows.new
  when 'fake'
    seed = options[:seed] ? JSON.parse(File.read(options[:seed])) : {}
    RegistryBackend::Fake.new(seed)
  else
    raise ArgumentError, "unknown backend: #{options[:backend]}"
  end
end

def write_backup(backups, backup_dir)
  return if backups.empty?

  FileUtils.mkdir_p(backup_dir)
  path = File.join(backup_dir, "registry_backup_#{Time.now.strftime('%Y%m%dT%H%M%S')}.json")
  File.write(path, JSON.pretty_generate(backups))
  path
end

def print_report(results)
  results.each do |r|
    icon = { match: 'OK   ', drift: 'DRIFT', missing: 'MISS ' }.fetch(r.status)
    puts "[#{icon}] #{r.rule.label} (expected=#{r.rule.expected.inspect}, actual=#{r.actual.inspect})"
  end
  counts = results.group_by(&:status).transform_values(&:size)
  puts
  puts "Summary: #{results.size} rules checked -- " \
       "#{counts.fetch(:match, 0)} match, #{counts.fetch(:drift, 0)} drift, #{counts.fetch(:missing, 0)} missing"
end

if $PROGRAM_NAME == __FILE__
  options = { backend: 'windows', fix: false, backup_dir: 'registry_backups' }
  OptionParser.new do |opts|
    opts.banner = 'Usage: registry_audit.rb --baseline baseline.yml [options]'
    opts.on('-b', '--baseline PATH', 'YAML file describing expected registry values') { |v| options[:baseline] = v }
    opts.on('--backend NAME', "Backend to use: 'windows' (default) or 'fake'") { |v| options[:backend] = v }
    opts.on('--seed PATH', 'JSON seed data for the fake backend (testing only)') { |v| options[:seed] = v }
    opts.on('--fix', 'Repair drifted/missing values after backing them up') { options[:fix] = true }
    opts.on('--backup-dir PATH', 'Where to write pre-fix JSON backups') { |v| options[:backup_dir] = v }
  end.parse!

  abort 'error: --baseline is required' unless options[:baseline]

  rules = load_rules(options[:baseline])
  backend = build_backend(options)
  auditor = Auditor.new(backend: backend, rules: rules)

  results = auditor.audit
  print_report(results)

  if options[:fix]
    backups = auditor.fix(results)
    if backups.empty?
      puts "\nNothing to fix -- all values already match the baseline."
    else
      backup_path = write_backup(backups, options[:backup_dir])
      puts "\nFixed #{backups.size} value(s). Previous values backed up to #{backup_path}"
    end
  end

  # Non-zero exit if anything was out of compliance -- handy for monitoring
  # systems that just check the exit code (e.g. a Nagios/Icinga check, or a
  # CI job that gates on configuration compliance).
  exit(results.all? { |r| r.status == :match } ? 0 : 1)
end

Baseline File

The baseline is a plain YAML list. Each entry names a hive, a subkey path, a value name, its type, and the value it’s expected to hold:

- hive: HKEY_LOCAL_MACHINE
  subkey: SOFTWARE\Policies\Contoso\Agent
  name: LogLevel
  type: REG_SZ
  expected: "warn"

- hive: HKEY_LOCAL_MACHINE
  subkey: SOFTWARE\Policies\Contoso\Agent
  name: HeartbeatSeconds
  type: REG_DWORD
  expected: 60

- hive: HKEY_LOCAL_MACHINE
  subkey: SOFTWARE\Policies\Contoso\Agent
  name: AllowRemoteAdmin
  type: REG_DWORD
  expected: 0

Run a read-only audit against the real registry:

ruby registry_audit.rb --baseline baseline.yml

Audit and repair, with backups written to a specific folder:

ruby registry_audit.rb --baseline baseline.yml --fix --backup-dir C:\registry_backups

How It Works

The design hinges on RegistryBackend, a narrow interface with two methods: read(hive, subkey, value_name) and write(hive, subkey, value_name, value, type). Everything else in the script talks to “a registry” through that interface without caring what’s actually on the other side:

  • RegistryBackend::Windows lazily requires win32/registry only when instantiated (so the file can still be loaded on non-Windows systems for testing), and maps the string hive/type names from your YAML baseline onto the actual Win32::Registry constants. A missing key or value raises Win32::Registry::Error, which is caught and treated as “missing” rather than a crash.
  • RegistryBackend::Fake is an in-memory hash-backed stand-in with the identical interface, seeded from a JSON snapshot. It exists purely so the rest of the script can be tested without a real registry.

Auditor loads the list of Rule structs from the baseline, and for each one asks the backend to read the actual value. Three outcomes: :match (actual equals expected), :drift (a value exists but doesn’t match), or :missing (nothing there at all). Auditor#fix walks the non-matching results, records a backup entry (the rule plus whatever the previous value was) for each one before writing anything, then calls backend.write to apply the baseline’s expected value. The backups are collected and only written to disk as a single timestamped JSON file if there’s actually something to fix.

The script exits non-zero if anything was out of compliance at audit time — deliberately checked against the pre-fix results, not the post-fix state — so a monitoring system (Nagios/Icinga check, a scheduled task that emails on failure, a CI gate) can alert on “this host was drifted” even in the same run that corrected it.

Testing It (Verified Logic)

Using the fake backend, seeded with a host that has a stale LogLevel, an AllowRemoteAdmin value flipped the wrong way, and a completely missing HeartbeatSeconds key:

Terminal output of registry_audit.rb detecting drift and applying a guarded fix

This confirmed, end to end, that: drift and missing values are both classified correctly; --fix only touches non-matching rules; a backup entry is written for every value before it’s overwritten, with the previous value preserved; and the exit code reflects the pre-fix compliance state. Re-running the audit after a fix (against a fresh backend seeded from the same data, since the fake backend is intentionally just an in-memory test double) confirmed the writes landed with the correct values and types.

Troubleshooting

  • LoadError: cannot load such file -- win32/registry: you’re running this on a non-Windows system, or on a Ruby build that doesn’t bundle the win32 extensions (some minimal/embedded Ruby installs omit them). Use --backend fake for testing on other platforms; the real backend only works on Windows.
  • Win32::Registry::Error mentioning access denied: you’re touching HKEY_LOCAL_MACHINE without administrator rights. Re-run from an elevated prompt, or restrict the baseline to HKEY_CURRENT_USER keys if that’s all you need.
  • A DWORD value shows as drifted even though it “looks the same” in regedit: make sure your YAML expected value is an actual integer (60), not a quoted string ("60") — YAML will happily let you write either, but the comparison is type-sensitive.
  • You want to preview changes without touching the registry at all: run without --fix first. The audit-only pass is fully read-only and safe to run as often as you like, including as a scheduled compliance check.
  • Backups directory keeps growing: each --fix run writes a new timestamped file rather than overwriting the last one, by design — you want a trail of what changed and when. Add your own retention/cleanup job if you need to prune old backups.

Extending It

  • Restore command: add a --restore <backup-file> mode that reads a backup JSON and writes each previous_value back, undoing a fix.
  • Multi-host rollout: combine this with the SSH/remote-execution tutorial in this series (or WinRM via the winrm gem) to run the same baseline audit across an entire fleet and aggregate the results into one compliance report.
  • Richer rule matching: extend Rule to support regex or “one of a list” expected values for cases where a value is allowed to vary within a known-safe range.
  • CI integration: since the script already exits non-zero on drift, wire it into a scheduled pipeline job and fail the build/alert when a golden-image snapshot doesn’t match its baseline anymore.

Leave a Reply

Your email address will not be published. Required fields are marked *