the shed // windows & wmi

Audit real BitLocker drive-encryption state via WMI — not the policy setting, the actual device state — and classify gaps by severity. Tested with a fake-WMI harness.

Step through the build below:

bitlocker_compliance_audit.rb

“Is every laptop actually encrypted?” is a routine compliance question — SOC 2, ISO 27001, a customer security questionnaire — that’s easy to get wrong by trusting a Group Policy setting instead of checking real device state.

bitlocker_compliance_audit.rb queries WMI’s Win32_EncryptableVolume class directly and checks the state that actually matters: is the volume fully encrypted, is protection active, and is there a recovery-password key protector so IT can actually unlock the drive if the TPM/PIN path fails. It’s read-only by design — it reports drift, it doesn’t flip encryption on for you.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# bitlocker_compliance_audit.rb
#
# Audits BitLocker drive-encryption status on Windows via WMI
# (Win32_EncryptableVolume) and reports which volumes are compliant with a
# simple, sane policy: fully encrypted, actively protected, and backed by a
# recovery-password key protector (so IT can actually unlock the drive if
# the TPM/PIN path fails). Read-only by design -- it reports drift, it
# doesn't flip encryption on for you, because enabling BitLocker involves
# choices (where the recovery key gets escrowed, whether to encrypt used
# space only or the whole drive) that shouldn't happen unattended.
#
# Why this exists: "is every laptop actually encrypted" is a routine
# compliance question (SOC 2, ISO 27001, a customer security questionnaire)
# that's easy to get wrong by trusting a policy setting instead of checking
# real device state. This script checks the real state, on every volume,
# and gives you a report you can hand to an auditor or wire into a fleet
# health check.
#
# IMPORTANT -- platform note:
# Win32_EncryptableVolume and WMI only exist on Windows (and require
# Administrator privileges to query). Exactly like this repo's other WMI
# tutorials, the *compliance decision logic* (BitLockerAuditor#evaluate) is
# fully platform-independent and unit-testable anywhere -- only the small
# WmiVolumeConnector class at the bottom touches WIN32OLE, and it's
# required lazily so this file can be required for tests on Linux/macOS
# without win32ole installed. See bitlocker_compliance_audit_test.rb for a
# fake-WMI test harness that exercises the real logic without Windows.
#
# Usage (on Windows, elevated):
#   ruby bitlocker_compliance_audit.rb
#   ruby bitlocker_compliance_audit.rb --json
#   ruby bitlocker_compliance_audit.rb --check   # exit 1 if any volume is non-compliant
#
# Requires: Ruby >= 2.7 built for Windows (e.g. via RubyInstaller) with the
# win32ole stdlib gem, and Administrator privileges (querying
# Win32_EncryptableVolume requires elevation even for read access).
require 'optparse'
require 'json'
# Friendly names for the WMI property codes on Win32_EncryptableVolume.
# See: https://learn.microsoft.com/en-us/windows/win32/secprov/win32-encryptablevolume
PROTECTION_STATUS = { 0 => 'Unprotected', 1 => 'Protected', 2 => 'Unknown' }.freeze
CONVERSION_STATUS = {
  0 => 'FullyDecrypted', 1 => 'FullyEncrypted', 2 => 'EncryptionInProgress',
  3 => 'DecryptionInProgress', 4 => 'EncryptionPaused', 5 => 'DecryptionPaused'
}.freeze
# Non-exhaustive; unrecognized codes are rendered as "Unknown(<code>)" rather
# than raising, since Microsoft has added new hardware/XTS variants over time.
ENCRYPTION_METHOD = {
  0 => 'None', 1 => 'AES_128_WITH_DIFFUSER', 2 => 'AES_256_WITH_DIFFUSER',
  3 => 'AES_128', 4 => 'AES_256', 6 => 'HW_AES_128', 7 => 'HW_AES_256'
}.freeze
KEY_PROTECTOR_TYPE = {
  0 => 'Unknown', 1 => 'TPM', 2 => 'ExternalKey', 3 => 'RecoveryPassword',
  4 => 'TPMAndPIN', 5 => 'TPMAndStartupKey', 6 => 'PublicKey', 7 => 'Password'
}.freeze
RECOVERY_PASSWORD_TYPE = 3
# One volume's audit outcome: whether it's compliant, how severe the gap is
# if not, and the specific reasons -- kept as a plain Struct so it
# serializes trivially to JSON for a compliance report or a monitoring feed.
VolumeAuditResult = Struct.new(
  :drive_letter, :compliant, :severity, :reasons,
  :protection_status, :conversion_status, :encryption_method, :has_recovery_password,
  keyword_init: true
) do
  def to_h
    {
      drive_letter: drive_letter,
      compliant: compliant,
      severity: severity,
      reasons: reasons,
      protection_status: protection_status,
      conversion_status: conversion_status,
      encryption_method: encryption_method,
      has_recovery_password: has_recovery_password
    }
  end
end
# BitLockerAuditor holds the actual compliance policy. It depends only on
# an injected `connector` responding to #volumes, which must return an
# array of plain hashes shaped like:
#   { drive_letter:, protection_status:, conversion_status:,
#     encryption_method:, key_protector_types: [...] }
# In production that's WmiVolumeConnector (real WIN32OLE calls); in tests
# it's a FakeVolumeConnector that hands back canned volume hashes directly,
# with no WMI or Windows involved at all.
class BitLockerAuditor
  def initialize(connector:)
    @connector = connector
  end
  # Audits every volume the connector reports and returns an array of
  # VolumeAuditResult, one per volume.
  def audit
    @connector.volumes.map { |vol| evaluate(vol) }
  end
  # Core compliance policy for a single volume. Pulled out as its own
  # method (rather than inlined in #audit) so tests can exercise it one
  # volume at a time with precise, hand-built fixtures.
  def evaluate(vol)
    reasons = []
    protection_name = PROTECTION_STATUS.fetch(vol[:protection_status], "Unknown(#{vol[:protection_status]})")
    conversion_name = CONVERSION_STATUS.fetch(vol[:conversion_status], "Unknown(#{vol[:conversion_status]})")
    method_name = ENCRYPTION_METHOD.fetch(vol[:encryption_method], "Unknown(#{vol[:encryption_method]})")
    has_recovery_password = Array(vol[:key_protector_types]).include?(RECOVERY_PASSWORD_TYPE)
    reasons << "protection is #{protection_name}, expected Protected" if vol[:protection_status] != 1
    reasons << "volume is #{conversion_name}, expected FullyEncrypted" if vol[:conversion_status] != 1
    reasons << 'no RecoveryPassword key protector configured' unless has_recovery_password
    severity =
      if reasons.empty?
        :ok
      elsif vol[:protection_status] != 1 || vol[:conversion_status] != 1
        :critical # the drive itself isn't encrypted/protected -- the real risk
      else
        :warning # encrypted and protected, but missing the recovery-password safety net
      end
    VolumeAuditResult.new(
      drive_letter: vol[:drive_letter],
      compliant: reasons.empty?,
      severity: severity,
      reasons: reasons,
      protection_status: protection_name,
      conversion_status: conversion_name,
      encryption_method: method_name,
      has_recovery_password: has_recovery_password
    )
  end
end
# --- Report rendering --------------------------------------------------------
def render_text(results)
  lines = []
  results.each do |r|
    tag = r.compliant ? 'PASS   ' : (r.severity == :critical ? 'CRIT   ' : 'WARN   ')
    lines << "#{tag} #{r.drive_letter}  method=#{r.encryption_method}  protection=#{r.protection_status}  conversion=#{r.conversion_status}  recovery_password=#{r.has_recovery_password}"
    r.reasons.each { |reason| lines << "         - #{reason}" }
  end
  lines.join("\n")
end
# --- Real WMI connector (Windows only) ---------------------------------------
class WmiVolumeConnector
  def initialize
    require 'win32ole' # raises LoadError on non-Windows -- expected and fine for tests
    @wmi = WIN32OLE.connect('winmgmts://./root/cimv2/security/MicrosoftVolumeEncryption')
  end
  def volumes
    result = []
    @wmi.ExecQuery('SELECT * FROM Win32_EncryptableVolume').each do |v|
      result << {
        drive_letter: v.DriveLetter,
        protection_status: v.ProtectionStatus,
        conversion_status: v.ConversionStatus,
        encryption_method: v.EncryptionMethod,
        key_protector_types: key_protector_types_for(v)
      }
    end
    result
  rescue StandardError => e
    warn "WMI query failed (are you running elevated?): #{e.message}"
    []
  end
  private
  # GetKeyProtectors(0) returns the IDs of all key protectors on the volume
  # (a filter value of 0 means "no type filter"); GetKeyProtectorType(id)
  # then resolves each ID to its numeric type code.
  def key_protector_types_for(volume)
    ids = volume.GetKeyProtectors(0)
    Array(ids).map { |id| volume.GetKeyProtectorType(id) }
  rescue StandardError
    []
  end
end
# --- CLI entry point ----------------------------------------------------------
if $PROGRAM_NAME == __FILE__
  options = { json: false, check: false }
  OptionParser.new do |opts|
    opts.banner = 'Usage: bitlocker_compliance_audit.rb [options]'
    opts.on('--json', 'Output the report as JSON instead of text') { options[:json] = true }
    opts.on('--check', 'Exit 1 if any volume is non-compliant (for cron/CI)') { options[:check] = true }
  end.parse!
  auditor = BitLockerAuditor.new(connector: WmiVolumeConnector.new)
  results = auditor.audit
  if options[:json]
    puts JSON.pretty_generate(results.map(&:to_h))
  else
    puts render_text(results)
  end
  exit(1) if options[:check] && results.any? { |r| !r.compliant }
end

Same separation of concerns as this repo’s other WMI tutorials: BitLockerAuditor#evaluate is the entire compliance policy, and it depends only on an injected connector responding to #volumes with plain hashes — it has no idea whether that data came from real WMI or a test fixture.

The policy classifies severity, not just pass/fail: a completely unencrypted or unprotected volume is :critical (the real risk), while an encrypted, protected volume that’s merely missing a recovery-password protector is only :warning — still non-compliant, but a materially smaller gap.

Only WmiVolumeConnector touches win32ole, required lazily inside its constructor, so this file can be require_relative‘d for tests on Linux without ever hitting a LoadError.

Test 1: fully encrypted, protected, with recovery password -> compliant
  PASS  compliant is true
  PASS  severity is :ok
  PASS  no reasons listed
  PASS  encryption_method resolved to AES_256
Test 2: completely unencrypted volume -> critical, non-compliant
  PASS  compliant is false
  PASS  severity is :critical
  PASS  reasons mention protection
  PASS  reasons mention conversion
  PASS  reasons mention missing recovery password
Test 3: encrypted and protected, but no recovery-password protector -> warning, non-compliant
  PASS  compliant is false
  PASS  severity is :warning, not :critical
  PASS  exactly one reason (missing recovery password)
Test 4: encryption in progress -> critical (not yet fully encrypted)
  PASS  compliant is false
  PASS  severity is :critical
  PASS  reason mentions EncryptionInProgress
Test 5: unknown/unrecognized WMI status codes render gracefully, no crash
  PASS  protection_status renders as Unknown(99)
  PASS  conversion_status renders as Unknown(42)
  PASS  encryption_method renders as Unknown(123)
  PASS  still classified non-compliant, not raised
Test 6: audit handles a mixed multi-volume fleet and preserves order
  PASS  returns 3 results in order C:,D:,E:
  PASS  C: compliant, D: critical, E: warning
Test 7: render_text and to_h produce sane, non-crashing output
  PASS  text report mentions all three drive letters
  PASS  to_h round-trips through JSON without error
============================================================
RESULTS: 23 passed, 0 failed
============================================================
Get the code

Full script + README on GitHub: ruby-devops-toolkit/bitlocker-compliance-audit

Platform note

Win32_EncryptableVolume and WMI only exist on Windows, and querying this class requires Administrator privileges even for read access. This tutorial's compliance logic is verified via a fake-WMI test harness in the sandbox (see the "output" tab above and the Troubleshooting section) rather than executed against a real encrypted volume.
Getting started

Prerequisites

You will need
  • Ruby ≥ 2.7 built for Windows (e.g. via RubyInstaller) with the win32ole stdlib gem — bundled by default with RubyInstaller’s Ruby
  • Administrator privileges to query Win32_EncryptableVolume (this is enforced by Windows regardless of what account runs the script)
  • Windows 10/11 Pro/Enterprise or Windows Server with BitLocker available
Reference

Full source

bitlocker_compliance_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# bitlocker_compliance_audit.rb
#
# Audits BitLocker drive-encryption status on Windows via WMI
# (Win32_EncryptableVolume) and reports which volumes are compliant with a
# simple, sane policy: fully encrypted, actively protected, and backed by a
# recovery-password key protector (so IT can actually unlock the drive if
# the TPM/PIN path fails). Read-only by design -- it reports drift, it
# doesn't flip encryption on for you, because enabling BitLocker involves
# choices (where the recovery key gets escrowed, whether to encrypt used
# space only or the whole drive) that shouldn't happen unattended.
#
# Why this exists: "is every laptop actually encrypted" is a routine
# compliance question (SOC 2, ISO 27001, a customer security questionnaire)
# that's easy to get wrong by trusting a policy setting instead of checking
# real device state. This script checks the real state, on every volume,
# and gives you a report you can hand to an auditor or wire into a fleet
# health check.
#
# IMPORTANT -- platform note:
# Win32_EncryptableVolume and WMI only exist on Windows (and require
# Administrator privileges to query). Exactly like this repo's other WMI
# tutorials, the *compliance decision logic* (BitLockerAuditor#evaluate) is
# fully platform-independent and unit-testable anywhere -- only the small
# WmiVolumeConnector class at the bottom touches WIN32OLE, and it's
# required lazily so this file can be required for tests on Linux/macOS
# without win32ole installed. See bitlocker_compliance_audit_test.rb for a
# fake-WMI test harness that exercises the real logic without Windows.
#
# Usage (on Windows, elevated):
#   ruby bitlocker_compliance_audit.rb
#   ruby bitlocker_compliance_audit.rb --json
#   ruby bitlocker_compliance_audit.rb --check   # exit 1 if any volume is non-compliant
#
# Requires: Ruby >= 2.7 built for Windows (e.g. via RubyInstaller) with the
# win32ole stdlib gem, and Administrator privileges (querying
# Win32_EncryptableVolume requires elevation even for read access).
require 'optparse'
require 'json'
# Friendly names for the WMI property codes on Win32_EncryptableVolume.
# See: https://learn.microsoft.com/en-us/windows/win32/secprov/win32-encryptablevolume
PROTECTION_STATUS = { 0 => 'Unprotected', 1 => 'Protected', 2 => 'Unknown' }.freeze
CONVERSION_STATUS = {
  0 => 'FullyDecrypted', 1 => 'FullyEncrypted', 2 => 'EncryptionInProgress',
  3 => 'DecryptionInProgress', 4 => 'EncryptionPaused', 5 => 'DecryptionPaused'
}.freeze
# Non-exhaustive; unrecognized codes are rendered as "Unknown(<code>)" rather
# than raising, since Microsoft has added new hardware/XTS variants over time.
ENCRYPTION_METHOD = {
  0 => 'None', 1 => 'AES_128_WITH_DIFFUSER', 2 => 'AES_256_WITH_DIFFUSER',
  3 => 'AES_128', 4 => 'AES_256', 6 => 'HW_AES_128', 7 => 'HW_AES_256'
}.freeze
KEY_PROTECTOR_TYPE = {
  0 => 'Unknown', 1 => 'TPM', 2 => 'ExternalKey', 3 => 'RecoveryPassword',
  4 => 'TPMAndPIN', 5 => 'TPMAndStartupKey', 6 => 'PublicKey', 7 => 'Password'
}.freeze
RECOVERY_PASSWORD_TYPE = 3
# One volume's audit outcome: whether it's compliant, how severe the gap is
# if not, and the specific reasons -- kept as a plain Struct so it
# serializes trivially to JSON for a compliance report or a monitoring feed.
VolumeAuditResult = Struct.new(
  :drive_letter, :compliant, :severity, :reasons,
  :protection_status, :conversion_status, :encryption_method, :has_recovery_password,
  keyword_init: true
) do
  def to_h
    {
      drive_letter: drive_letter,
      compliant: compliant,
      severity: severity,
      reasons: reasons,
      protection_status: protection_status,
      conversion_status: conversion_status,
      encryption_method: encryption_method,
      has_recovery_password: has_recovery_password
    }
  end
end
# BitLockerAuditor holds the actual compliance policy. It depends only on
# an injected `connector` responding to #volumes, which must return an
# array of plain hashes shaped like:
#   { drive_letter:, protection_status:, conversion_status:,
#     encryption_method:, key_protector_types: [...] }
# In production that's WmiVolumeConnector (real WIN32OLE calls); in tests
# it's a FakeVolumeConnector that hands back canned volume hashes directly,
# with no WMI or Windows involved at all.
class BitLockerAuditor
  def initialize(connector:)
    @connector = connector
  end
  # Audits every volume the connector reports and returns an array of
  # VolumeAuditResult, one per volume.
  def audit
    @connector.volumes.map { |vol| evaluate(vol) }
  end
  # Core compliance policy for a single volume. Pulled out as its own
  # method (rather than inlined in #audit) so tests can exercise it one
  # volume at a time with precise, hand-built fixtures.
  def evaluate(vol)
    reasons = []
    protection_name = PROTECTION_STATUS.fetch(vol[:protection_status], "Unknown(#{vol[:protection_status]})")
    conversion_name = CONVERSION_STATUS.fetch(vol[:conversion_status], "Unknown(#{vol[:conversion_status]})")
    method_name = ENCRYPTION_METHOD.fetch(vol[:encryption_method], "Unknown(#{vol[:encryption_method]})")
    has_recovery_password = Array(vol[:key_protector_types]).include?(RECOVERY_PASSWORD_TYPE)
    reasons << "protection is #{protection_name}, expected Protected" if vol[:protection_status] != 1
    reasons << "volume is #{conversion_name}, expected FullyEncrypted" if vol[:conversion_status] != 1
    reasons << 'no RecoveryPassword key protector configured' unless has_recovery_password
    severity =
      if reasons.empty?
        :ok
      elsif vol[:protection_status] != 1 || vol[:conversion_status] != 1
        :critical # the drive itself isn't encrypted/protected -- the real risk
      else
        :warning # encrypted and protected, but missing the recovery-password safety net
      end
    VolumeAuditResult.new(
      drive_letter: vol[:drive_letter],
      compliant: reasons.empty?,
      severity: severity,
      reasons: reasons,
      protection_status: protection_name,
      conversion_status: conversion_name,
      encryption_method: method_name,
      has_recovery_password: has_recovery_password
    )
  end
end
# --- Report rendering --------------------------------------------------------
def render_text(results)
  lines = []
  results.each do |r|
    tag = r.compliant ? 'PASS   ' : (r.severity == :critical ? 'CRIT   ' : 'WARN   ')
    lines << "#{tag} #{r.drive_letter}  method=#{r.encryption_method}  protection=#{r.protection_status}  conversion=#{r.conversion_status}  recovery_password=#{r.has_recovery_password}"
    r.reasons.each { |reason| lines << "         - #{reason}" }
  end
  lines.join("\n")
end
# --- Real WMI connector (Windows only) ---------------------------------------
class WmiVolumeConnector
  def initialize
    require 'win32ole' # raises LoadError on non-Windows -- expected and fine for tests
    @wmi = WIN32OLE.connect('winmgmts://./root/cimv2/security/MicrosoftVolumeEncryption')
  end
  def volumes
    result = []
    @wmi.ExecQuery('SELECT * FROM Win32_EncryptableVolume').each do |v|
      result << {
        drive_letter: v.DriveLetter,
        protection_status: v.ProtectionStatus,
        conversion_status: v.ConversionStatus,
        encryption_method: v.EncryptionMethod,
        key_protector_types: key_protector_types_for(v)
      }
    end
    result
  rescue StandardError => e
    warn "WMI query failed (are you running elevated?): #{e.message}"
    []
  end
  private
  # GetKeyProtectors(0) returns the IDs of all key protectors on the volume
  # (a filter value of 0 means "no type filter"); GetKeyProtectorType(id)
  # then resolves each ID to its numeric type code.
  def key_protector_types_for(volume)
    ids = volume.GetKeyProtectors(0)
    Array(ids).map { |id| volume.GetKeyProtectorType(id) }
  rescue StandardError
    []
  end
end
# --- CLI entry point ----------------------------------------------------------
if $PROGRAM_NAME == __FILE__
  options = { json: false, check: false }
  OptionParser.new do |opts|
    opts.banner = 'Usage: bitlocker_compliance_audit.rb [options]'
    opts.on('--json', 'Output the report as JSON instead of text') { options[:json] = true }
    opts.on('--check', 'Exit 1 if any volume is non-compliant (for cron/CI)') { options[:check] = true }
  end.parse!
  auditor = BitLockerAuditor.new(connector: WmiVolumeConnector.new)
  results = auditor.audit
  if options[:json]
    puts JSON.pretty_generate(results.map(&:to_h))
  else
    puts render_text(results)
  end
  exit(1) if options[:check] && results.any? { |r| !r.compliant }
end
bitlocker_compliance_audit.rb compliance decision flow diagram

Compliance decision flow: WMI volume data in, severity-classified pass/fail report out.
How it works

Step-by-step walkthrough

Core pieces
  • BitLockerAuditor#evaluate — the entire compliance policy for one volume: checks ProtectionStatus == Protected, ConversionStatus == FullyEncrypted, and whether a RecoveryPassword-type key protector is present.
  • Severity classification — missing encryption/protection is :critical; encrypted-and-protected-but-no-recovery-password is only :warning; everything in order is :ok.
  • WmiVolumeConnector — the only class touching WIN32OLE. Queries Win32_EncryptableVolume over the root/cimv2/security/MicrosoftVolumeEncryption WMI namespace, then calls the real GetKeyProtectors(0) / GetKeyProtectorType(id) COM methods to resolve which protector types are configured on each volume.
  • Graceful unknown-code handling — any WMI status code this script doesn’t recognize renders as Unknown(<code>) instead of raising, since Microsoft has added new encryption-method variants over time.
Verified without Windows

Test harness &amp; output

How this was actually tested

Since win32ole/WMI don’t exist on Linux, the real WmiVolumeConnector class is not exercised by these tests — it’s a thin wrapper around one WMI query and two COM method calls, and is documented here as untested-on-Linux. Instead, bitlocker_compliance_audit_test.rb defines a FakeVolumeConnector that hands back canned volume hashes and drives the real BitLockerAuditor policy through 7 scenarios / 23 assertions — all passing, including unknown-status-code handling and a mixed multi-volume fleet.

bitlocker_compliance_audit_test.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# bitlocker_compliance_audit_test.rb
#
# A stub/mock test harness for bitlocker_compliance_audit.rb. Win32_EncryptableVolume
# and WMI only exist on Windows, so this harness fakes the connector so the
# real compliance policy in BitLockerAuditor can be verified on any
# platform, including this Linux sandbox. This does NOT test the real
# WmiVolumeConnector class -- that class is a thin wrapper around a WMI
# query and two COM method calls, and is documented as untested-on-Linux in
# the tutorial itself.
#
# Run with: ruby bitlocker_compliance_audit_test.rb
require_relative 'bitlocker_compliance_audit'
# A trivial stand-in for WmiVolumeConnector: just hands back whatever
# volume hashes were configured, in the same shape the real connector
# would produce from Win32_EncryptableVolume.
class FakeVolumeConnector
  def initialize(volumes)
    @volumes = volumes
  end
  def volumes
    @volumes
  end
end
$failures = 0
$passes = 0
def check(description)
  result = yield
  if result
    $passes += 1
    puts "  PASS  #{description}"
  else
    $failures += 1
    puts "  FAIL  #{description}"
  end
end
puts 'Test 1: fully encrypted, protected, with recovery password -> compliant'
fake = FakeVolumeConnector.new([
  { drive_letter: 'C:', protection_status: 1, conversion_status: 1, encryption_method: 4, key_protector_types: [1, 3] }
])
auditor = BitLockerAuditor.new(connector: fake)
result = auditor.audit.first
check('compliant is true') { result.compliant == true }
check('severity is :ok') { result.severity == :ok }
check('no reasons listed') { result.reasons.empty? }
check('encryption_method resolved to AES_256') { result.encryption_method == 'AES_256' }
puts "\nTest 2: completely unencrypted volume -> critical, non-compliant"
fake = FakeVolumeConnector.new([
  { drive_letter: 'D:', protection_status: 0, conversion_status: 0, encryption_method: 0, key_protector_types: [] }
])
result = BitLockerAuditor.new(connector: fake).audit.first
check('compliant is false') { result.compliant == false }
check('severity is :critical') { result.severity == :critical }
check('reasons mention protection') { result.reasons.any? { |r| r.include?('protection is Unprotected') } }
check('reasons mention conversion') { result.reasons.any? { |r| r.include?('FullyDecrypted') } }
check('reasons mention missing recovery password') { result.reasons.any? { |r| r.include?('RecoveryPassword') } }
puts "\nTest 3: encrypted and protected, but no recovery-password protector -> warning, non-compliant"
fake = FakeVolumeConnector.new([
  { drive_letter: 'C:', protection_status: 1, conversion_status: 1, encryption_method: 4, key_protector_types: [1] } # TPM only
])
result = BitLockerAuditor.new(connector: fake).audit.first
check('compliant is false') { result.compliant == false }
check('severity is :warning, not :critical') { result.severity == :warning }
check('exactly one reason (missing recovery password)') { result.reasons.size == 1 }
puts "\nTest 4: encryption in progress -> critical (not yet fully encrypted)"
fake = FakeVolumeConnector.new([
  { drive_letter: 'E:', protection_status: 1, conversion_status: 2, encryption_method: 4, key_protector_types: [3] }
])
result = BitLockerAuditor.new(connector: fake).audit.first
check('compliant is false') { result.compliant == false }
check('severity is :critical') { result.severity == :critical }
check('reason mentions EncryptionInProgress') { result.reasons.any? { |r| r.include?('EncryptionInProgress') } }
puts "\nTest 5: unknown/unrecognized WMI status codes render gracefully, no crash"
fake = FakeVolumeConnector.new([
  { drive_letter: 'F:', protection_status: 99, conversion_status: 42, encryption_method: 123, key_protector_types: [] }
])
result = BitLockerAuditor.new(connector: fake).audit.first
check('protection_status renders as Unknown(99)') { result.protection_status == 'Unknown(99)' }
check('conversion_status renders as Unknown(42)') { result.conversion_status == 'Unknown(42)' }
check('encryption_method renders as Unknown(123)') { result.encryption_method == 'Unknown(123)' }
check('still classified non-compliant, not raised') { result.compliant == false }
puts "\nTest 6: audit handles a mixed multi-volume fleet and preserves order"
fake = FakeVolumeConnector.new([
  { drive_letter: 'C:', protection_status: 1, conversion_status: 1, encryption_method: 4, key_protector_types: [1, 3] },
  { drive_letter: 'D:', protection_status: 0, conversion_status: 0, encryption_method: 0, key_protector_types: [] },
  { drive_letter: 'E:', protection_status: 1, conversion_status: 1, encryption_method: 4, key_protector_types: [1] }
])
results = BitLockerAuditor.new(connector: fake).audit
check('returns 3 results in order C:,D:,E:') { results.map(&:drive_letter) == ['C:', 'D:', 'E:'] }
check('C: compliant, D: critical, E: warning') do
  results[0].severity == :ok && results[1].severity == :critical && results[2].severity == :warning
end
puts "\nTest 7: render_text and to_h produce sane, non-crashing output"
text = render_text(results)
check('text report mentions all three drive letters') { %w[C: D: E:].all? { |d| text.include?(d) } }
check('to_h round-trips through JSON without error') do
  require 'json'
  JSON.parse(JSON.generate(results.first.to_h))
  true
rescue StandardError
  false
end
puts "\n#{'=' * 60}"
puts "RESULTS: #{$passes} passed, #{$failures} failed"
puts '=' * 60
exit($failures.zero? ? 0 : 1)
ruby bitlocker_compliance_audit_test.rb
Test 1: fully encrypted, protected, with recovery password -> compliant
PASS compliant is true
PASS severity is :ok
PASS no reasons listed
PASS encryption_method resolved to AES_256
Test 2: completely unencrypted volume -> critical, non-compliant
PASS compliant is false
PASS severity is :critical
PASS reasons mention protection
PASS reasons mention conversion
PASS reasons mention missing recovery password
Test 3: encrypted and protected, but no recovery-password protector -> warning, non-compliant
PASS compliant is false
PASS severity is :warning, not :critical
PASS exactly one reason (missing recovery password)
Test 4: encryption in progress -> critical (not yet fully encrypted)
PASS compliant is false
PASS severity is :critical
PASS reason mentions EncryptionInProgress
Test 5: unknown/unrecognized WMI status codes render gracefully, no crash
PASS protection_status renders as Unknown(99)
PASS conversion_status renders as Unknown(42)
PASS encryption_method renders as Unknown(123)
PASS still classified non-compliant, not raised
Test 6: audit handles a mixed multi-volume fleet and preserves order
PASS returns 3 results in order C:,D:,E:
PASS C: compliant, D: critical, E: warning
Test 7: render_text and to_h produce sane, non-crashing output
PASS text report mentions all three drive letters
PASS to_h round-trips through JSON without error
============================================================
RESULTS: 23 passed, 0 failed
============================================================
When things go wrong

Troubleshooting

Common issues
  • “cannot load such file — win32ole” on Linux/macOS — expected and by design; run the real script only on Windows and use bitlocker_compliance_audit_test.rb to validate logic changes anywhere else.
  • WMI query fails / returns nothing, even on a Windows box — querying Win32_EncryptableVolume requires Administrator privileges; run the terminal elevated.
  • A volume shows encrypted in the BitLocker Control Panel but this script flags it — check whether it’s specifically missing a RecoveryPassword protector (TPM-only setups are common and will correctly show as a WARNING here, not a false positive).
  • Removable/USB volumes reporting oddlyWin32_EncryptableVolume covers BitLocker To Go volumes too; a plugged-out drive will simply not appear in the query results.
Where to take it next

Extending this script

Ideas
  • Point WIN32OLE.connect at a remote host’s WMI namespace to audit a small fleet from one box (same pattern as this repo’s other remote-WMI ideas).
  • Add a --csv output mode for feeding straight into a spreadsheet-based compliance tracker.
  • Cross-reference results against Active Directory/Intune to flag devices that report compliant to MDM but fail this direct check (or vice versa).
  • Extend FakeVolumeConnector with a scenario for a locked, un-unlocked volume (LockStatus) to harden the policy further.