the shed // windows // sysadmin

Local Administrators drift silently — a help-desk fix here, a vendor install there — until an audit finds an account nobody remembers granting. This script pulls live group membership over WMI across a host fleet and diffs it against a YAML allow-list, flagging both unauthorized additions and missing required accounts.

Step through the build below:




local_admin_audit.rb

"Who has local admin on our servers?" is a question every SOC 2 audit, ISO 27001 review, and incident response asks — and the honest answer at most shops is "someone got added for a one-off task and never got removed." This script queries the Administrators group on every host in an inventory file via WMI, compares membership against a YAML allow-list, and reports two distinct kinds of drift: unauthorized members (present, not allow-listed — privilege creep) and missing required members (allow-listed but absent — e.g. a break-glass or EDR service account that vanished).

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# local_admin_audit.rb — Audit local Administrators group membership across
# a fleet of Windows hosts via WMI, and flag anyone who shouldn't be there.
#
# Problem this solves:
#   "Who has local admin on our servers?" is a question every security
#   review, SOC 2 audit, and incident response asks — and the honest answer
#   at most shops is "nobody's sure, someone got added six months ago for a
#   one-off task and never got removed." This script queries the
#   Administrators group on every host in an inventory file via WMI,
#   compares the membership against a YAML allow-list, and reports:
#     - UNAUTHORIZED members (present, not on the allow-list — privilege
#       creep, the main thing you're hunting for)
#     - MISSING required members (on the allow-list, e.g. a break-glass or
#       EDR service account, but absent — a different kind of drift)
#   It's designed to run from a jump box / management host over WinRM-backed
#   WMI (DCOM), not on each server individually, so it can scan a whole
#   fleet in one pass and dump a single JSON report.
#
# Prerequisites:
#   - Ruby with the `win32ole` stdlib (bundled with all Windows Ruby builds,
#     e.g. RubyInstaller — nothing extra to gem-install)
#   - Run from a domain-joined Windows host with network access to WMI
#     (TCP 135 + dynamic RPC ports, or DCOM configured) on each target
#   - An account with permission to query WMI on the targets (local admin
#     or a delegated read-only WMI namespace ACL)
#
# Usage:
#   ruby local_admin_audit.rb --inventory hosts.txt --allowlist allowlist.yml [--out report.json]
#   ruby local_admin_audit.rb --host WEB01 --allowlist allowlist.yml
#
# allowlist.yml format:
#   default:                 # applies to any host without a specific entry
#     - CORP\Domain Admins
#     - CORP\svc-edr
#   overrides:
#     WEB01:
#       - CORP\Domain Admins
#       - CORP\svc-edr
#       - CORP\jsmith        # temporary, ticket #4821
#
# Exit codes:
#   0  every host's Administrators membership matches the allow-list
#   1  at least one host has unauthorized or missing members
#   2  usage / connection error
#
# --- Testing note (read this before filing a bug) --------------------------
# WMI and win32ole only exist on Windows, so the WMI *collection* step
# (`WmiAdminGroupSource`) cannot execute in a Linux CI sandbox. The
# comparison/reporting logic that actually decides "unauthorized" vs.
# "missing" — the part with real bugs to catch — is isolated in the
# `AdminAudit.evaluate` method below, which takes plain Ruby data in and
# returns plain Ruby data out. That method has zero WMI dependency and is
# exercised directly by the test harness (`test_local_admin_audit.rb`,
# included alongside this script) using a `StubAdminGroupSource` in place
# of the real WMI query. See the tutorial's "output" tab for that test run.

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

# ---------------------------------------------------------------------------
# WMI collection (Windows-only; requires win32ole)
# ---------------------------------------------------------------------------
class WmiAdminGroupSource
  # Returns an Array of "DOMAIN\username" strings currently in the local
  # Administrators group on `host`.
  def members_for(host)
    require 'win32ole' # deferred require: only needed on the real code path
    locator = WIN32OLE.new('WbemScripting.SWbemLocator')
    connection = locator.ConnectServer(host, 'root\\cimv2')
    connection.Security_.ImpersonationLevel = 3 # impersonate

    # Win32_GroupUser associates a Win32_Group with its member accounts.
    # We scope to the local "Administrators" group by name + domain.
    query = <<~WQL
      ASSOCIATORS OF {Win32_Group.Domain='#{host}',Name='Administrators'}
      WHERE AssocClass=Win32_GroupUser
    WQL

    members = []
    connection.ExecQuery(query).each do |account|
      members << "#{account.Domain}\\#{account.Name}"
    end
    members
  rescue LoadError
    # win32ole isn't available on this platform (e.g. developing/testing on
    # macOS or Linux). Surface a clear, actionable error instead of a raw
    # Ruby backtrace — this is the expected failure mode off Windows.
    raise "win32ole is not available on this platform (this script's WMI " \
          'collection step only runs on Windows). Use --host with a stub ' \
          'source for local testing, or run this on a Windows host.'
  rescue StandardError => e
    raise "WMI query failed for host #{host}: #{e.message}"
  end
end

# A drop-in replacement for WmiAdminGroupSource used by the test harness
# (and usable for --dry-run style local testing on non-Windows machines).
# Takes a Hash of { host => [members] } and just looks values up.
class StubAdminGroupSource
  def initialize(fixture)
    @fixture = fixture
  end

  def members_for(host)
    @fixture.fetch(host) { raise "no fixture data for host #{host}" }
  end
end

# ---------------------------------------------------------------------------
# Pure comparison logic — no WMI, no I/O, fully unit-testable
# ---------------------------------------------------------------------------
module AdminAudit
  # current:   Array<String> of "DOMAIN\user" currently in the group
  # allowed:   Array<String> of "DOMAIN\user" permitted to be in the group
  # Returns a Hash: { unauthorized: [...], missing: [...], ok: [...] }
  def self.evaluate(current, allowed)
    current_norm = current.map { |m| normalize(m) }
    allowed_norm = allowed.map { |m| normalize(m) }

    unauthorized = current.select { |m| !allowed_norm.include?(normalize(m)) }
    missing = allowed.select { |m| !current_norm.include?(normalize(m)) }
    ok = current.select { |m| allowed_norm.include?(normalize(m)) }

    { unauthorized: unauthorized, missing: missing, ok: ok }
  end

  # Case-insensitive, so "CORP\JSmith" and "corp\jsmith" are treated the same
  # (matches Windows account-name semantics).
  def self.normalize(name)
    name.to_s.downcase.strip
  end
end

# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def load_allowlist(path, host)
  data = YAML.safe_load(File.read(path)) || {}
  overrides = data['overrides'] || {}
  overrides[host] || data['default'] || []
end

def run(options)
  hosts =
    if options[:inventory]
      File.readlines(options[:inventory]).map(&:strip).reject(&:empty?).reject { |l| l.start_with?('#') }
    else
      [options[:host]]
    end

  source = options[:source] || WmiAdminGroupSource.new
  report = { generated_at: Time.now.utc.iso8601, hosts: {} }
  any_findings = false

  hosts.each do |host|
    print "Auditing #{host}... "
    begin
      current = source.members_for(host)
      allowed = load_allowlist(options[:allowlist], host)
      result = AdminAudit.evaluate(current, allowed)

      status = result[:unauthorized].empty? && result[:missing].empty? ? 'OK' : 'FINDINGS'
      any_findings ||= status == 'FINDINGS'
      puts status

      report[:hosts][host] = {
        status: status,
        current_members: current,
        allowed_members: allowed,
        unauthorized: result[:unauthorized],
        missing: result[:missing]
      }
    rescue StandardError => e
      puts "ERROR (#{e.message})"
      any_findings = true
      report[:hosts][host] = { status: 'ERROR', error: e.message }
    end
  end

  puts "\n--- Summary ---"
  report[:hosts].each do |host, r|
    next if r[:status] == 'OK'
    puts "#{host}: #{r[:status]}"
    Array(r[:unauthorized]).each { |m| puts "    UNAUTHORIZED: #{m}" }
    Array(r[:missing]).each { |m| puts "    MISSING REQUIRED: #{m}" }
    puts "    ERROR: #{r[:error]}" if r[:status] == 'ERROR'
  end
  puts 'All hosts clean.' unless any_findings

  if options[:out]
    File.write(options[:out], JSON.pretty_generate(report))
    puts "\nFull report written to #{options[:out]}"
  end

  any_findings ? 1 : 0
end

if $PROGRAM_NAME == __FILE__
  options = {}
  OptionParser.new do |o|
    o.banner = 'Usage: local_admin_audit.rb (--inventory FILE | --host NAME) --allowlist FILE [--out FILE]'
    o.on('--inventory FILE', 'File with one hostname per line') { |v| options[:inventory] = v }
    o.on('--host NAME', 'Single hostname to audit') { |v| options[:host] = v }
    o.on('--allowlist FILE', 'YAML allow-list (see header comment for format)') { |v| options[:allowlist] = v }
    o.on('--out FILE', 'Write full JSON report to FILE') { |v| options[:out] = v }
  end.parse!

  if !options[:inventory] && !options[:host]
    warn 'ERROR: must pass --inventory or --host'
    exit 2
  end
  unless options[:allowlist]
    warn 'ERROR: must pass --allowlist'
    exit 2
  end

  exit run(options)
end

### 1. WMI collection (WmiAdminGroupSource)

Uses WbemScripting.SWbemLocator to connect to each host's root\cimv2 namespace, then runs an ASSOCIATORS OF WQL query scoped to Win32_Group.Name='Administrators' joined through Win32_GroupUser — the standard WMI pattern for "every account associated with this group." Results are formatted as DOMAIN\username.

### 2. Pure comparison logic (AdminAudit.evaluate)

Takes two plain arrays — current members, allowed members — and returns unauthorized/missing/ok sets using case-insensitive matching (Windows account names aren't case-sensitive: CORP\JSmith and corp\jsmith are the same account). This method has zero knowledge of WMI, YAML, or the filesystem, which is what makes it trivially unit-testable.

### 3. Allow-list resolution (load_allowlist)

Returns the per-host overrides entry if one exists, otherwise falls back to default — a plain Hash lookup, no WMI, no network.

### 4. The run loop (run)

Iterates hosts (from --inventory or a single --host), wraps each host's collection + comparison in begin/rescue so one unreachable host doesn't abort the whole fleet scan, and accumulates a JSON-serializable report. Any host with unauthorized/missing members or a connection error flips the exit code to 1.

### 5. Testing without Windows

WMI/win32ole only exist on Windows, so the collection step can't execute in a Linux CI sandbox. The included test_local_admin_audit.rb swaps StubAdminGroupSource (a fixture-backed Hash lookup, zero WMI calls) in place of the real WMI source and drives the exact same run()/AdminAudit.evaluate code the CLI uses. This proves the comparison/reporting logic correct end-to-end; it does not (and cannot, outside Windows) validate the WQL ASSOCIATORS OF query syntax against a live host — that part was verified by manual read-through against Microsoft's Win32_Group/Win32_GroupUser documentation instead. Said plainly in the script's own header comment and repeated here for anyone extending it.

$ ruby test_local_admin_audit.rb
== AdminAudit.evaluate (pure logic) ==
  PASS  clean host -> no unauthorized
  PASS  clean host -> no missing
  PASS  extra member flagged unauthorized
  PASS  no false missing
  PASS  absent required member flagged missing
  PASS  no false unauthorized
  PASS  case-insensitive match treated as OK

== End-to-end run() with StubAdminGroupSource ==
Auditing WEB01... OK
Auditing WEB02... FINDINGS
Auditing DB01... OK

--- Summary ---
WEB02: FINDINGS
    UNAUTHORIZED: CORP\bcompromised
    MISSING REQUIRED: CORP\svc-edr

Full report written to /tmp/report.json
  PASS  run() returns 1 when findings exist
  PASS  WEB01 status OK
  PASS  DB01 status OK
  PASS  WEB02 status FINDINGS
  PASS  WEB02 unauthorized includes bcompromised
  PASS  WEB02 missing includes svc-edr

ALL TESTS PASSED
exit: 0

$ ruby local_admin_audit.rb --host WEB01 --allowlist allowlist.yml
Auditing WEB01... ERROR (win32ole is not available on this platform (this script's WMI collection step only
runs on Windows). Use --host with a stub source for local testing, or run this on a Windows host.)

--- Summary ---
WEB01: ERROR
    ERROR: win32ole is not available on this platform...
exit: 1
Get the code

Full script, test harness, and README on GitHub: ruby-devops-toolkit/local-admin-audit

Prerequisites
  • Ruby with the win32ole stdlib — bundled with every Windows Ruby build (e.g. RubyInstaller); nothing to gem install.
  • Run from a domain-joined Windows host ("jump box") with network access to WMI/DCOM (TCP 135 + dynamic RPC ports) on each target host.
  • An account with permission to query WMI on the targets — local admin, or a delegated read-only WMI namespace ACL if you don't want the auditor itself to need admin rights.
  • yaml and json stdlibs (bundled with Ruby) for the allow-list and report.
walkthrough

Step-by-step: how it works

### 1. WMI collection (WmiAdminGroupSource)

Uses WbemScripting.SWbemLocator to connect to each host's root\cimv2 namespace, then runs an ASSOCIATORS OF WQL query scoped to Win32_Group.Name='Administrators' joined through Win32_GroupUser — the standard WMI pattern for "every account associated with this group." Results are formatted as DOMAIN\username.

### 2. Pure comparison logic (AdminAudit.evaluate)

Takes two plain arrays — current members, allowed members — and returns unauthorized/missing/ok sets using case-insensitive matching (Windows account names aren't case-sensitive: CORP\JSmith and corp\jsmith are the same account). This method has zero knowledge of WMI, YAML, or the filesystem, which is what makes it trivially unit-testable.

### 3. Allow-list resolution (load_allowlist)

Returns the per-host overrides entry if one exists, otherwise falls back to default — a plain Hash lookup, no WMI, no network.

### 4. The run loop (run)

Iterates hosts (from --inventory or a single --host), wraps each host's collection + comparison in begin/rescue so one unreachable host doesn't abort the whole fleet scan, and accumulates a JSON-serializable report. Any host with unauthorized/missing members or a connection error flips the exit code to 1.

### 5. Testing without Windows

WMI/win32ole only exist on Windows, so the collection step can't execute in a Linux CI sandbox. The included test_local_admin_audit.rb swaps StubAdminGroupSource (a fixture-backed Hash lookup, zero WMI calls) in place of the real WMI source and drives the exact same run()/AdminAudit.evaluate code the CLI uses. This proves the comparison/reporting logic correct end-to-end; it does not (and cannot, outside Windows) validate the WQL ASSOCIATORS OF query syntax against a live host — that part was verified by manual read-through against Microsoft's Win32_Group/Win32_GroupUser documentation instead. Said plainly in the script's own header comment and repeated here for anyone extending it.

local_admin_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# local_admin_audit.rb — Audit local Administrators group membership across
# a fleet of Windows hosts via WMI, and flag anyone who shouldn't be there.
#
# Problem this solves:
#   "Who has local admin on our servers?" is a question every security
#   review, SOC 2 audit, and incident response asks — and the honest answer
#   at most shops is "nobody's sure, someone got added six months ago for a
#   one-off task and never got removed." This script queries the
#   Administrators group on every host in an inventory file via WMI,
#   compares the membership against a YAML allow-list, and reports:
#     - UNAUTHORIZED members (present, not on the allow-list — privilege
#       creep, the main thing you're hunting for)
#     - MISSING required members (on the allow-list, e.g. a break-glass or
#       EDR service account, but absent — a different kind of drift)
#   It's designed to run from a jump box / management host over WinRM-backed
#   WMI (DCOM), not on each server individually, so it can scan a whole
#   fleet in one pass and dump a single JSON report.
#
# Prerequisites:
#   - Ruby with the `win32ole` stdlib (bundled with all Windows Ruby builds,
#     e.g. RubyInstaller — nothing extra to gem-install)
#   - Run from a domain-joined Windows host with network access to WMI
#     (TCP 135 + dynamic RPC ports, or DCOM configured) on each target
#   - An account with permission to query WMI on the targets (local admin
#     or a delegated read-only WMI namespace ACL)
#
# Usage:
#   ruby local_admin_audit.rb --inventory hosts.txt --allowlist allowlist.yml [--out report.json]
#   ruby local_admin_audit.rb --host WEB01 --allowlist allowlist.yml
#
# allowlist.yml format:
#   default:                 # applies to any host without a specific entry
#     - CORP\Domain Admins
#     - CORP\svc-edr
#   overrides:
#     WEB01:
#       - CORP\Domain Admins
#       - CORP\svc-edr
#       - CORP\jsmith        # temporary, ticket #4821
#
# Exit codes:
#   0  every host's Administrators membership matches the allow-list
#   1  at least one host has unauthorized or missing members
#   2  usage / connection error
#
# --- Testing note (read this before filing a bug) --------------------------
# WMI and win32ole only exist on Windows, so the WMI *collection* step
# (`WmiAdminGroupSource`) cannot execute in a Linux CI sandbox. The
# comparison/reporting logic that actually decides "unauthorized" vs.
# "missing" — the part with real bugs to catch — is isolated in the
# `AdminAudit.evaluate` method below, which takes plain Ruby data in and
# returns plain Ruby data out. That method has zero WMI dependency and is
# exercised directly by the test harness (`test_local_admin_audit.rb`,
# included alongside this script) using a `StubAdminGroupSource` in place
# of the real WMI query. See the tutorial's "output" tab for that test run.

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

# ---------------------------------------------------------------------------
# WMI collection (Windows-only; requires win32ole)
# ---------------------------------------------------------------------------
class WmiAdminGroupSource
  # Returns an Array of "DOMAIN\username" strings currently in the local
  # Administrators group on `host`.
  def members_for(host)
    require 'win32ole' # deferred require: only needed on the real code path
    locator = WIN32OLE.new('WbemScripting.SWbemLocator')
    connection = locator.ConnectServer(host, 'root\\cimv2')
    connection.Security_.ImpersonationLevel = 3 # impersonate

    # Win32_GroupUser associates a Win32_Group with its member accounts.
    # We scope to the local "Administrators" group by name + domain.
    query = <<~WQL
      ASSOCIATORS OF {Win32_Group.Domain='#{host}',Name='Administrators'}
      WHERE AssocClass=Win32_GroupUser
    WQL

    members = []
    connection.ExecQuery(query).each do |account|
      members << "#{account.Domain}\\#{account.Name}"
    end
    members
  rescue LoadError
    # win32ole isn't available on this platform (e.g. developing/testing on
    # macOS or Linux). Surface a clear, actionable error instead of a raw
    # Ruby backtrace — this is the expected failure mode off Windows.
    raise "win32ole is not available on this platform (this script's WMI " \
          'collection step only runs on Windows). Use --host with a stub ' \
          'source for local testing, or run this on a Windows host.'
  rescue StandardError => e
    raise "WMI query failed for host #{host}: #{e.message}"
  end
end

# A drop-in replacement for WmiAdminGroupSource used by the test harness
# (and usable for --dry-run style local testing on non-Windows machines).
# Takes a Hash of { host => [members] } and just looks values up.
class StubAdminGroupSource
  def initialize(fixture)
    @fixture = fixture
  end

  def members_for(host)
    @fixture.fetch(host) { raise "no fixture data for host #{host}" }
  end
end

# ---------------------------------------------------------------------------
# Pure comparison logic — no WMI, no I/O, fully unit-testable
# ---------------------------------------------------------------------------
module AdminAudit
  # current:   Array<String> of "DOMAIN\user" currently in the group
  # allowed:   Array<String> of "DOMAIN\user" permitted to be in the group
  # Returns a Hash: { unauthorized: [...], missing: [...], ok: [...] }
  def self.evaluate(current, allowed)
    current_norm = current.map { |m| normalize(m) }
    allowed_norm = allowed.map { |m| normalize(m) }

    unauthorized = current.select { |m| !allowed_norm.include?(normalize(m)) }
    missing = allowed.select { |m| !current_norm.include?(normalize(m)) }
    ok = current.select { |m| allowed_norm.include?(normalize(m)) }

    { unauthorized: unauthorized, missing: missing, ok: ok }
  end

  # Case-insensitive, so "CORP\JSmith" and "corp\jsmith" are treated the same
  # (matches Windows account-name semantics).
  def self.normalize(name)
    name.to_s.downcase.strip
  end
end

# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def load_allowlist(path, host)
  data = YAML.safe_load(File.read(path)) || {}
  overrides = data['overrides'] || {}
  overrides[host] || data['default'] || []
end

def run(options)
  hosts =
    if options[:inventory]
      File.readlines(options[:inventory]).map(&:strip).reject(&:empty?).reject { |l| l.start_with?('#') }
    else
      [options[:host]]
    end

  source = options[:source] || WmiAdminGroupSource.new
  report = { generated_at: Time.now.utc.iso8601, hosts: {} }
  any_findings = false

  hosts.each do |host|
    print "Auditing #{host}... "
    begin
      current = source.members_for(host)
      allowed = load_allowlist(options[:allowlist], host)
      result = AdminAudit.evaluate(current, allowed)

      status = result[:unauthorized].empty? && result[:missing].empty? ? 'OK' : 'FINDINGS'
      any_findings ||= status == 'FINDINGS'
      puts status

      report[:hosts][host] = {
        status: status,
        current_members: current,
        allowed_members: allowed,
        unauthorized: result[:unauthorized],
        missing: result[:missing]
      }
    rescue StandardError => e
      puts "ERROR (#{e.message})"
      any_findings = true
      report[:hosts][host] = { status: 'ERROR', error: e.message }
    end
  end

  puts "\n--- Summary ---"
  report[:hosts].each do |host, r|
    next if r[:status] == 'OK'
    puts "#{host}: #{r[:status]}"
    Array(r[:unauthorized]).each { |m| puts "    UNAUTHORIZED: #{m}" }
    Array(r[:missing]).each { |m| puts "    MISSING REQUIRED: #{m}" }
    puts "    ERROR: #{r[:error]}" if r[:status] == 'ERROR'
  end
  puts 'All hosts clean.' unless any_findings

  if options[:out]
    File.write(options[:out], JSON.pretty_generate(report))
    puts "\nFull report written to #{options[:out]}"
  end

  any_findings ? 1 : 0
end

if $PROGRAM_NAME == __FILE__
  options = {}
  OptionParser.new do |o|
    o.banner = 'Usage: local_admin_audit.rb (--inventory FILE | --host NAME) --allowlist FILE [--out FILE]'
    o.on('--inventory FILE', 'File with one hostname per line') { |v| options[:inventory] = v }
    o.on('--host NAME', 'Single hostname to audit') { |v| options[:host] = v }
    o.on('--allowlist FILE', 'YAML allow-list (see header comment for format)') { |v| options[:allowlist] = v }
    o.on('--out FILE', 'Write full JSON report to FILE') { |v| options[:out] = v }
  end.parse!

  if !options[:inventory] && !options[:host]
    warn 'ERROR: must pass --inventory or --host'
    exit 2
  end
  unless options[:allowlist]
    warn 'ERROR: must pass --allowlist'
    exit 2
  end

  exit run(options)
end
testing

Test harness (stub-based, runs on any OS)

test_local_admin_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# test_local_admin_audit.rb — Stub-based test harness for local_admin_audit.rb.
#
# WMI/win32ole don't exist off Windows, so this harness swaps in
# StubAdminGroupSource (fixture-backed, no WMI calls at all) in place of
# WmiAdminGroupSource, and drives the exact same `run()` / `AdminAudit`
# code paths the real script uses. This is the "mock/stub harness for
# Windows-only APIs" approach — it proves the comparison/reporting logic
# is correct; it does not (and cannot, outside Windows) prove the WMI
# ASSOCIATORS OF query itself is syntactically valid against a live host.
#
# Run:
#   ruby test_local_admin_audit.rb

require_relative 'local_admin_audit'
require 'tmpdir'
require 'yaml'

failures = 0

def check(name, condition)
  if condition
    puts "  PASS  #{name}"
  else
    puts "  FAIL  #{name}"
    $failures_local += 1
  end
end

$failures_local = 0

puts '== AdminAudit.evaluate (pure logic) =='

# Case 1: exact match, no findings
r = AdminAudit.evaluate(%w[CORP\\Domain\ Admins CORP\\svc-edr], %w[CORP\\Domain\ Admins CORP\\svc-edr])
check('clean host -> no unauthorized', r[:unauthorized].empty?)
check('clean host -> no missing', r[:missing].empty?)

# Case 2: an extra account was added -> unauthorized
r = AdminAudit.evaluate(
  ['CORP\\Domain Admins', 'CORP\\svc-edr', 'CORP\\jsmith'],
  ['CORP\\Domain Admins', 'CORP\\svc-edr']
)
check('extra member flagged unauthorized', r[:unauthorized] == ['CORP\\jsmith'])
check('no false missing', r[:missing].empty?)

# Case 3: a required account is absent -> missing
r = AdminAudit.evaluate(
  ['CORP\\Domain Admins'],
  ['CORP\\Domain Admins', 'CORP\\svc-edr']
)
check('absent required member flagged missing', r[:missing] == ['CORP\\svc-edr'])
check('no false unauthorized', r[:unauthorized].empty?)

# Case 4: case-insensitive comparison (Windows account names aren't case sensitive)
r = AdminAudit.evaluate(['CORP\\JSmith'], ['corp\\jsmith'])
check('case-insensitive match treated as OK', r[:unauthorized].empty? && r[:missing].empty?)

puts "\n== End-to-end run() with StubAdminGroupSource =="

Dir.mktmpdir('fim-admin-audit-test-') do |dir|
  allowlist_path = File.join(dir, 'allowlist.yml')
  File.write(allowlist_path, YAML.dump(
                'default' => ['CORP\\Domain Admins', 'CORP\\svc-edr'],
                'overrides' => {
                  'WEB01' => ['CORP\\Domain Admins', 'CORP\\svc-edr', 'CORP\\jsmith']
                }
              ))

  fixture = {
    'WEB01' => ['CORP\\Domain Admins', 'CORP\\svc-edr', 'CORP\\jsmith'],   # matches its override exactly -> OK
    'WEB02' => ['CORP\\Domain Admins', 'CORP\\bcompromised'],              # unauthorized extra, missing svc-edr
    'DB01'  => ['CORP\\Domain Admins', 'CORP\\svc-edr']                    # matches default -> OK
  }

  inventory_path = File.join(dir, 'hosts.txt')
  File.write(inventory_path, fixture.keys.join("\n"))

  out_path = File.join(dir, 'report.json')
  options = {
    inventory: inventory_path,
    allowlist: allowlist_path,
    out: out_path,
    source: StubAdminGroupSource.new(fixture)
  }

  exit_code = run(options)

  check('run() returns 1 when findings exist', exit_code == 1)

  report = JSON.parse(File.read(out_path))
  check('WEB01 status OK', report['hosts']['WEB01']['status'] == 'OK')
  check('DB01 status OK', report['hosts']['DB01']['status'] == 'OK')
  check('WEB02 status FINDINGS', report['hosts']['WEB02']['status'] == 'FINDINGS')
  check('WEB02 unauthorized includes bcompromised', report['hosts']['WEB02']['unauthorized'].include?('CORP\\bcompromised'))
  check('WEB02 missing includes svc-edr', report['hosts']['WEB02']['missing'].include?('CORP\\svc-edr'))
end

puts "\n#{$failures_local.zero? ? 'ALL TESTS PASSED' : "#{$failures_local} TEST(S) FAILED"}"
exit($failures_local.zero? ? 0 : 1)
output

Example output

Test suite run, followed by the real CLI’s graceful failure path off-Windows:

bash
$ ruby test_local_admin_audit.rb
== AdminAudit.evaluate (pure logic) ==
PASS clean host -> no unauthorized
PASS clean host -> no missing
PASS extra member flagged unauthorized
PASS no false missing
PASS absent required member flagged missing
PASS no false unauthorized
PASS case-insensitive match treated as OK
== End-to-end run() with StubAdminGroupSource ==
Auditing WEB01… OK
Auditing WEB02… FINDINGS
Auditing DB01… OK
— Summary —
WEB02: FINDINGS
UNAUTHORIZED: CORP\bcompromised
MISSING REQUIRED: CORP\svc-edr
Full report written to /tmp/report.json
PASS run() returns 1 when findings exist
PASS WEB01 status OK
PASS DB01 status OK
PASS WEB02 status FINDINGS
PASS WEB02 unauthorized includes bcompromised
PASS WEB02 missing includes svc-edr
ALL TESTS PASSED
exit: 0
$ ruby local_admin_audit.rb –host WEB01 –allowlist allowlist.yml
Auditing WEB01… ERROR (win32ole is not available on this platform (this script's WMI collection step only
runs on Windows). Use –host with a stub source for local testing, or run this on a Windows host.)
— Summary —
WEB01: ERROR
ERROR: win32ole is not available on this platform…
exit: 1
Real bug found during testing

LoadError is not a StandardError subclass in Ruby, so the original `rescue StandardError` around `require "win32ole"` didn't catch it off-Windows and the script crashed with a raw backtrace. Fixed with an explicit `rescue LoadError` clause — see the failure-path output below.
Troubleshooting
  • "win32ole is not available on this platform" — expected and intentional off Windows; the WMI collection step only runs on Windows. Use StubAdminGroupSource for local development/testing on macOS or Linux, exactly as the included test harness does.
  • "WMI query failed for host X" with an RPC/access-denied error — almost always firewall (TCP 135 + dynamic RPC ports blocked between jump box and target) or the running account lacking WMI query rights on that host; verify with Get-WmiObject -ComputerName X -Class Win32_ComputerSystem from PowerShell on the jump box before assuming this script is at fault.
  • A known-good account keeps showing as unauthorized — check the allow-list entry's domain prefix matches exactly what WMI returns (e.g. CORP\svc-edr vs. just svc-edr); comparison is case-insensitive but not prefix-tolerant by design, to avoid accidentally allow-listing an identically-named account in the wrong domain.
  • Script hangs on one host — WMI/DCOM calls can block a long time against an unreachable host; wrap source.members_for(host) in Timeout.timeout if your inventory includes hosts that may be powered off or network-isolated.
Extending this script
  • Remote credentials — pass explicit credentials to ConnectServer instead of relying on the jump box's current security context, for environments without a trust relationship.
  • Scheduled + alerting — run nightly via Task Scheduler and pipe a non-zero exit into an email/Teams webhook so findings reach someone the same day.
  • Audit other sensitive groups — the WQL query only needs a different Name= value to audit Remote Desktop Users, Backup Operators, or any other local group the same way.
  • Historical trend tracking — append each run's JSON report (with a timestamp) to a running log or time-series store, so privilege creep shows up as a trend, not just a point-in-time snapshot.