Auditing the Windows Registry with Ruby: A win32/registry Compliance Checker

DevOps / System Administration · Windows Server · Ruby 3.x

The problem this solves

Security hardening baselines (CIS benchmarks, internal standards, audit checklists) almost always come down to a list of specific Windows Registry values: is NLA required for RDP, is SMBv1 disabled, is Defender’s real-time protection left on, is AutoRun turned off. Checking these by hand across a fleet of servers with regedit doesn’t scale, and Group Policy compliance reports don’t always tell you what’s actually set on a given box right now. This tutorial builds a small Ruby auditor that reads a list of registry values, compares each against an expected setting, prints a PASS/FAIL report (plus a JSON version for feeding into a dashboard), and can optionally remediate failing values in place with --fix.

Prerequisites

  • Ruby 2.7+ for Windows (e.g. via RubyInstaller), tested against Ruby 3.0
  • The win32/registry standard library, which ships with Windows Ruby installs (it is not available on Linux/macOS Ruby — see the testing note below)
  • Administrator privileges to read (and, with --fix, write) the relevant HKEY_LOCAL_MACHINE keys
  • Windows Server 2016+ or Windows 10/11

A note on testing this script: win32/registry only exists on Windows, so the real registry access code (WindowsRegistryAccessor) can’t run in a Linux sandbox. As with any Windows-only automation, the fix is to isolate registry access behind a narrow interface (RegistryAccessor) and test everything else — the rule engine, PASS/FAIL evaluation, remediation logic, and reporting — against a FakeRegistryAccessor test double backed by an in-memory hash. That’s what was actually exercised to verify the behavior documented below; only the real Windows accessor itself needs a Windows host to confirm.

How it works

Windows registry auditor flow: Security Baseline to Registry Accessor to PASS or FAIL, with pass leading to report and fail leading to optional fix

  • RegistryRule — declares one baseline check: which hive/key/value to look at, what value is expected, and a severity label
  • RegistryAccessor — an abstract interface with read and write, so the rule engine never touches the registry API directly
  • WindowsRegistryAccessor — the real implementation, backed by Win32::Registry
  • ComplianceAuditor — evaluates every rule against an accessor and, on --fix, writes the expected value for anything that failed and re-checks
  • ReportPrinter — prints a PASS/FAIL table and can write a JSON report for downstream tooling

The complete code

Save this as registry_audit.rb:

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# registry_audit.rb
#
# Audits a set of Windows Registry values against a small security baseline
# (the kind of checks a CIS benchmark or internal hardening standard asks
# for: RDP Network Level Authentication enabled, SMBv1 disabled, Windows
# Defender not switched off, etc.), reports PASS/FAIL per rule, and can
# optionally remediate failing values with --fix. Solves the "which of our
# 200 servers actually has the hardening settings we think they have"
# problem without needing a full configuration management stack.
#
# Usage (on a Windows host, from an elevated shell):
#   ruby registry_audit.rb
#   ruby registry_audit.rb --fix
#   ruby registry_audit.rb --demo   (runs against an in-memory fake registry, no Windows needed)

require 'optparse'
require 'json'
require 'time'

# --- Abstraction: anything that can read/write a registry value -----------
# Keeping registry access behind a narrow interface is what lets the audit
# rules and reporting logic be exercised without a real Windows host.
class RegistryAccessor
  def read(_hive, _key, _value_name)
    raise NotImplementedError
  end

  def write(_hive, _key, _value_name, _data, _type)
    raise NotImplementedError
  end
end

# --- Real Windows implementation, backed by the win32/registry stdlib -----
class WindowsRegistryAccessor < RegistryAccessor
  HIVE_MAP = {
    'HKLM' => :HKEY_LOCAL_MACHINE,
    'HKCU' => :HKEY_CURRENT_USER
  }.freeze

  def initialize
    require 'win32/registry' # only present on Windows Ruby installs
    @win32_registry = Win32::Registry
  end

  def read(hive, key, value_name)
    hive_const = @win32_registry.const_get(HIVE_MAP.fetch(hive))
    hive_const.open(key) { |reg| reg[value_name] }
  rescue Win32::Registry::Error
    nil # key or value doesn't exist
  end

  def write(hive, key, value_name, data, type)
    hive_const = @win32_registry.const_get(HIVE_MAP.fetch(hive))
    type_const = @win32_registry.const_get(type) # e.g. :REG_DWORD
    hive_const.open(key, Win32::Registry::KEY_SET_VALUE) do |reg|
      reg.write(value_name, type_const, data)
    end
    true
  rescue Win32::Registry::Error => e
    warn "failed to write #{hive}\\#{key}\\#{value_name}: #{e.message}"
    false
  end
end

# --- Cross-platform logic below: rules, evaluation, reporting -------------

# One baseline check: where to look, what value is expected, and a
# human-readable name/description for the report.
RegistryRule = Struct.new(
  :id, :description, :hive, :key, :value_name, :expected, :type, :severity,
  keyword_init: true
)

# The security baseline itself. In a real deployment this would likely be
# loaded from a YAML/JSON file rather than hardcoded, so different teams can
# maintain their own baseline without touching the script - see "Extending
# this script" below.
DEFAULT_BASELINE = [
  RegistryRule.new(
    id: 'rdp_nla_required',
    description: 'RDP requires Network Level Authentication',
    hive: 'HKLM',
    key: 'SYSTEM\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp',
    value_name: 'UserAuthentication',
    expected: 1,
    type: :REG_DWORD,
    severity: 'high'
  ),
  RegistryRule.new(
    id: 'smb1_disabled',
    description: 'SMBv1 server protocol is disabled',
    hive: 'HKLM',
    key: 'SYSTEM\\CurrentControlSet\\Services\\LanmanServer\\Parameters',
    value_name: 'SMB1',
    expected: 0,
    type: :REG_DWORD,
    severity: 'critical'
  ),
  RegistryRule.new(
    id: 'defender_realtime_enabled',
    description: 'Windows Defender real-time protection is not disabled',
    hive: 'HKLM',
    key: 'SOFTWARE\\Policies\\Microsoft\\Windows Defender\\Real-Time Protection',
    value_name: 'DisableRealtimeMonitoring',
    expected: 0,
    type: :REG_DWORD,
    severity: 'critical'
  ),
  RegistryRule.new(
    id: 'autorun_disabled',
    description: 'AutoRun is disabled for all drives',
    hive: 'HKLM',
    key: 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\Explorer',
    value_name: 'NoDriveTypeAutoRun',
    expected: 255,
    type: :REG_DWORD,
    severity: 'medium'
  )
].freeze

# Result of checking a single rule.
CheckResult = Struct.new(:rule, :actual, :passed, keyword_init: true)

# Evaluates the baseline against a RegistryAccessor and can remediate
# failing values.
class ComplianceAuditor
  def initialize(accessor, baseline: DEFAULT_BASELINE)
    @accessor = accessor
    @baseline = baseline
  end

  def audit
    @baseline.map do |rule|
      actual = @accessor.read(rule.hive, rule.key, rule.value_name)
      CheckResult.new(rule: rule, actual: actual, passed: actual == rule.expected)
    end
  end

  # Writes the expected value for every failing rule. Returns the results
  # re-checked after remediation so the caller can confirm it worked.
  def remediate(results)
    results.select { |r| !r.passed }.each do |result|
      rule = result.rule
      @accessor.write(rule.hive, rule.key, rule.value_name, rule.expected, rule.type)
    end
    audit
  end
end

# Prints a PASS/FAIL table and writes a JSON report for machine consumption
# (e.g. feeding into a fleet-wide compliance dashboard).
class ReportPrinter
  def self.print_table(results)
    results.each do |r|
      status = r.passed ? 'PASS' : 'FAIL'
      puts format('[%s] (%s) %-30s expected=%-5s actual=%-5s',
                   status, r.rule.severity, r.rule.id, r.rule.expected.inspect, r.actual.inspect)
    end
    fails = results.count { |r| !r.passed }
    puts "\n#{results.size - fails}/#{results.size} checks passed"
  end

  def self.write_json(results, path)
    payload = {
      generated_at: Time.now.utc.iso8601,
      results: results.map do |r|
        {
          id: r.rule.id,
          description: r.rule.description,
          severity: r.rule.severity,
          expected: r.rule.expected,
          actual: r.actual,
          passed: r.passed
        }
      end
    }
    File.write(path, JSON.pretty_generate(payload))
  end
end

# --- Test double used for local verification & CI (no Windows needed) -----
# Simulates a registry as an in-memory hash keyed by [hive, key, value_name],
# so the rule engine, pass/fail logic, and remediation path can be exercised
# without touching a real Windows registry.
class FakeRegistryAccessor < RegistryAccessor
  def initialize(seed = {})
    @store = seed.dup
  end

  def read(hive, key, value_name)
    @store[[hive, key, value_name]]
  end

  def write(hive, key, value_name, data, _type)
    @store[[hive, key, value_name]] = data
    true
  end
end

if __FILE__ == $PROGRAM_NAME
  options = { fix: false, demo: false, json: nil }

  OptionParser.new do |opts|
    opts.banner = 'Usage: registry_audit.rb [options]'
    opts.on('--fix', 'Remediate failing checks by writing the expected values') { options[:fix] = true }
    opts.on('--demo', 'Run against an in-memory fake registry instead of the real one') { options[:demo] = true }
    opts.on('--json PATH', 'Also write a JSON report to PATH') { |v| options[:json] = v }
  end.parse!

  accessor = if options[:demo]
               # Seed with a mix of compliant and non-compliant values so the
               # demo exercises both the PASS path and the --fix path.
               FakeRegistryAccessor.new(
                 ['HKLM', 'SYSTEM\\CurrentControlSet\\Control\\Terminal Server\\WinStations\\RDP-Tcp', 'UserAuthentication'] => 1,
                 ['HKLM', 'SYSTEM\\CurrentControlSet\\Services\\LanmanServer\\Parameters', 'SMB1'] => 1,
                 ['HKLM', 'SOFTWARE\\Policies\\Microsoft\\Windows Defender\\Real-Time Protection', 'DisableRealtimeMonitoring'] => 0
                 # autorun_disabled intentionally left unset (nil) to show a missing-value failure
               )
             else
               WindowsRegistryAccessor.new
             end

  auditor = ComplianceAuditor.new(accessor)
  results = auditor.audit
  ReportPrinter.print_table(results)

  if options[:fix]
    failing_before = results.count { |r| !r.passed }
    if failing_before.zero?
      puts "\nNothing to fix - all checks already pass."
    else
      puts "\nRemediating #{failing_before} failing check(s)..."
      results = auditor.remediate(results)
      puts "\nAfter remediation:"
      ReportPrinter.print_table(results)
    end
  end

  ReportPrinter.write_json(results, options[:json]) if options[:json]
end

Step-by-step walkthrough

1. Declaring the baseline as data, not code

Each RegistryRule is a plain struct: hive, key path, value name, expected value, registry type, and a severity label. DEFAULT_BASELINE is just an array of these. Keeping the baseline as data (rather than scattering if checks through the script) is what makes it easy to grow — adding a new check is adding one more RegistryRule.new(...) entry, and the rest of the pipeline (audit, report, remediate) handles it automatically.

2. Reading through an abstraction, not Win32::Registry directly

WindowsRegistryAccessor#read opens the appropriate hive and looks up the value, catching Win32::Registry::Error and returning nil for a missing key or value — which is itself a meaningful audit result (a security setting that was never configured at all is exactly as much a finding as one explicitly set wrong).

3. Comparing and reporting

ComplianceAuditor#audit maps every rule to a CheckResult by comparing the actual registry value against the rule’s expected value. ReportPrinter.print_table then renders a fixed-width PASS/FAIL table including each check’s severity, and write_json produces a machine-readable equivalent suitable for feeding into a fleet-wide dashboard that aggregates results across many hosts.

4. Remediating safely, only what’s broken

--fix only touches rules that are currently failing — ComplianceAuditor#remediate filters to results.select { |r| !r.passed } before writing anything, then re-runs the full audit so you see a confirmed after-state, not just an assumption that the write succeeded.

Example output

Since win32/registry only exists on Windows, this script includes a --demo mode that exercises the exact same rule engine and reporting code against FakeRegistryAccessor. Real output from running it:

$ ruby registry_audit.rb --demo
[PASS] (high) rdp_nla_required               expected=1     actual=1
[FAIL] (critical) smb1_disabled                  expected=0     actual=1
[PASS] (critical) defender_realtime_enabled      expected=0     actual=0
[FAIL] (medium) autorun_disabled               expected=255   actual=nil

2/4 checks passed

$ ruby registry_audit.rb --demo --fix --json demo_report.json
[PASS] (high) rdp_nla_required               expected=1     actual=1
[FAIL] (critical) smb1_disabled                  expected=0     actual=1
[PASS] (critical) defender_realtime_enabled      expected=0     actual=0
[FAIL] (medium) autorun_disabled               expected=255   actual=nil

2/4 checks passed

Remediating 2 failing check(s)...

After remediation:
[PASS] (high) rdp_nla_required               expected=1     actual=1
[PASS] (critical) smb1_disabled                  expected=0     actual=0
[PASS] (critical) defender_realtime_enabled      expected=0     actual=0
[PASS] (medium) autorun_disabled               expected=255   actual=255

4/4 checks passed

smb1_disabled starts non-compliant (SMB1 was set to 1, meaning enabled) and autorun_disabled was never configured at all (nil). Both fail the initial audit, both get corrected by --fix, and the re-audit confirms all four checks pass afterward — the exact remediation loop this tool is meant to automate across a fleet.

Running it fleet-wide with Task Scheduler

schtasks /create /tn "RubyRegistryAudit" /tr "ruby.exe C:\scripts\registry_audit.rb --json C:\reports\registry_audit.json" /sc daily /st 06:00 /ru SYSTEM

Ship the resulting JSON to wherever you centralize compliance data (a shared network path, an internal API, a log aggregator) to build a per-host pass/fail view across your whole environment.

Troubleshooting

Symptom Likely cause / fix
LoadError: cannot load such file -- win32/registry You’re running on non-Windows Ruby, or a minimal install missing the standard library. Use the official RubyInstaller package, or run --demo to exercise the logic elsewhere.
write fails with an access error even as admin Some keys require running from an elevated (Run as Administrator) shell even for an admin account — UAC still applies to registry writes under HKLM.
A check reports FAIL with actual=nil when you expect a value Confirm the exact key path and value name with reg query "HKLM\...\ " from an elevated Command Prompt — a missing or misspelled subkey is a very common cause.
--fix doesn’t seem to persist after reboot Check for a Group Policy Object re-applying the old value on refresh — GPO takes precedence and will silently overwrite a manual/scripted registry fix.
Different value types than expected (string vs DWORD) Make sure type: on the RegistryRule matches what the key actually expects (e.g. :REG_DWORD vs :REG_SZ); a type mismatch on write will raise Win32::Registry::Error.

Extending this script

  • Load the baseline from an external YAML/JSON file so security and ops teams can maintain hardening rules without editing Ruby code
  • Add remote auditing by connecting to a different machine’s registry hive path (win32/registry supports remote registry access with the right permissions/service running)
  • Map each rule to its corresponding CIS Benchmark or STIG identifier in the report for direct audit traceability
  • Add a --baseline-diff mode that compares two JSON reports (e.g. before/after a change) and highlights what changed
  • Integrate with the Windows Event Log so every --fix remediation is recorded centrally, not just in the local JSON report

Leave a Reply

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