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:
“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 ============================================================
Full script + README on GitHub: ruby-devops-toolkit/bitlocker-compliance-audit
Prerequisites
- Ruby ≥ 2.7 built for Windows (e.g. via RubyInstaller) with the
win32olestdlib 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
Full source
#!/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
Step-by-step walkthrough
BitLockerAuditor#evaluate— the entire compliance policy for one volume: checksProtectionStatus == Protected,ConversionStatus == FullyEncrypted, and whether aRecoveryPassword-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 touchingWIN32OLE. QueriesWin32_EncryptableVolumeover theroot/cimv2/security/MicrosoftVolumeEncryptionWMI namespace, then calls the realGetKeyProtectors(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.
Test harness & output
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.
#!/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)
Troubleshooting
- “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.rbto validate logic changes anywhere else. - WMI query fails / returns nothing, even on a Windows box — querying
Win32_EncryptableVolumerequires 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 oddly —
Win32_EncryptableVolumecovers BitLocker To Go volumes too; a plugged-out drive will simply not appear in the query results.
Extending this script
- Point
WIN32OLE.connectat 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
--csvoutput 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
FakeVolumeConnectorwith a scenario for a locked, un-unlocked volume (LockStatus) to harden the policy further.