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.

Prerequisites
- Ruby 2.7+ on Windows — the RubyInstaller builds ship
win32/registryin the standard library, so there’s nothing togem installfor the registry access itself - Administrator privileges if you’re auditing/fixing
HKEY_LOCAL_MACHINEvalues (standard user rights are enough forHKEY_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::Windowslazilyrequireswin32/registryonly 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 actualWin32::Registryconstants. A missing key or value raisesWin32::Registry::Error, which is caught and treated as “missing” rather than a crash.RegistryBackend::Fakeis 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:

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 fakefor testing on other platforms; the real backend only works on Windows.Win32::Registry::Errormentioning access denied: you’re touchingHKEY_LOCAL_MACHINEwithout administrator rights. Re-run from an elevated prompt, or restrict the baseline toHKEY_CURRENT_USERkeys if that’s all you need.- A DWORD value shows as drifted even though it “looks the same” in regedit: make sure your YAML
expectedvalue 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
--fixfirst. 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
--fixrun 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 eachprevious_valueback, undoing a fix. - Multi-host rollout: combine this with the SSH/remote-execution tutorial in this series (or WinRM via the
winrmgem) to run the same baseline audit across an entire fleet and aggregate the results into one compliance report. - Richer rule matching: extend
Ruleto 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.


