Exposed Remote Desktop is still the most common way ransomware gets in. The settings that decide whether your RDP is safe live in three registry hives — and Group Policy silently overrides the local ones. This Ruby script reads all of them with the bundled win32/registry library and grades the host PASS / WARN / FAIL.
Full script + README on GitHub: ruby-devops-toolkit/win-rdp-hardening-audit
Step through the build below:
The symptom: you have 200 Windows servers, a CIS benchmark that says ‘NLA required, TLS security layer, high encryption, 15-minute idle timeout, no clipboard redirection’, and no cheap way to prove which hosts comply. Get-ItemProperty one-liners work but do not know that a Group Policy value in HKLM\SOFTWARE\Policies beats the one under WinStations\RDP-Tcp.
The approach: a table of checks, each with a location, a pass predicate and a plain-English explanation. A policy_or_local helper implements the GPO precedence. The firewall check parses the raw FirewallRules strings to see whether the inbound RDP rule is scoped to a subnet or open to the world.
What you get: a readable table, --json for your SIEM, exit 0/1/2, and a --fixture mode so the logic runs on Linux CI with no Windows at all.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_rdp_hardening_audit.rb -- audit Remote Desktop (RDP) hardening on a
# Windows host using only Ruby's bundled win32/registry library.
#
# Exposed RDP is the #1 initial-access vector for ransomware crews, and the
# settings that matter are scattered across three registry hives. This script
# reads each one, scores it against a baseline (CIS / Microsoft security
# baseline defaults), and prints a pass/fail table plus a JSON blob you can
# ship to your SIEM.
#
# Checks:
# * RDP enabled at all? (fDenyTSConnections)
# * Network Level Authentication on? (UserAuthentication)
# * TLS required for the RDP transport? (SecurityLayer)
# * Encryption level High/FIPS? (MinEncryptionLevel)
# * Listening port changed from 3389? (PortNumber -- informational)
# * Idle/disconnect session timeouts? (MaxIdleTime / MaxDisconnectionTime)
# * Clipboard / drive redirection off? (fDisableClip / fDisableCdm)
# * Windows Firewall RDP rule scope (via Windows Firewall registry)
# * Restrict local admin RDP via policy? (fPromptForPassword)
#
# Usage (on Windows, elevated prompt recommended):
# ruby win_rdp_hardening_audit.rb # table
# ruby win_rdp_hardening_audit.rb --json # JSON
# ruby win_rdp_hardening_audit.rb --fixture rdp_fixture.json # test anywhere
#
# Exit codes: 0 = all pass, 1 = warnings only, 2 = at least one FAIL.
require 'json'
require 'optparse'
# ----------------------------------------------------------------------------
# Registry access layer.
#
# RegistryReader talks to the real registry through win32/registry (bundled
# with the RubyInstaller builds). FixtureReader loads a JSON file of the same
# shape so the audit logic can be tested on Linux/macOS or in CI. The audit
# only ever calls #read(hive, key, value) so the two are interchangeable.
# ----------------------------------------------------------------------------
class RegistryReader
def initialize
require 'win32/registry'
@hives = {
'HKLM' => Win32::Registry::HKEY_LOCAL_MACHINE,
'HKCU' => Win32::Registry::HKEY_CURRENT_USER
}
end
# Returns the value, or nil if the key/value does not exist.
def read(hive, key, value)
# KEY_READ | KEY_WOW64_64KEY so a 32-bit Ruby still sees the 64-bit view.
access = Win32::Registry::KEY_READ | 0x0100
@hives.fetch(hive).open(key, access) { |reg| reg[value] }
rescue Win32::Registry::Error
nil
end
end
class FixtureReader
def initialize(path)
@data = JSON.parse(File.read(path))
end
def read(hive, key, value)
@data.dig(hive, key, value)
end
end
# ----------------------------------------------------------------------------
# The checks. Each is a hash describing where the value lives, what "good"
# looks like, and how to explain a failure to a human.
# ----------------------------------------------------------------------------
TS = 'SYSTEM\CurrentControlSet\Control\Terminal Server'
RDP_TCP = "#{TS}\\WinStations\\RDP-Tcp"
POLICY = 'SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services'
FW_RULES = 'SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\FirewallRules'
# A value can be set by Group Policy (POLICY hive) which overrides the local
# WinStations setting. We check policy first, then fall back to the local key.
def policy_or_local(reg, value)
v = reg.read('HKLM', POLICY, value)
v.nil? ? reg.read('HKLM', RDP_TCP, value) : v
end
CHECKS = [
{
id: 'rdp_enabled',
title: 'Remote Desktop enabled',
severity: :info,
fetch: ->(r) { r.read('HKLM', TS, 'fDenyTSConnections') },
pass: ->(v) { v == 1 },
explain: ->(v) { v == 1 ? 'RDP disabled (fDenyTSConnections=1)' : 'RDP is ENABLED; remaining checks matter' }
},
{
id: 'nla_required',
title: 'Network Level Authentication required',
severity: :fail,
fetch: ->(r) { policy_or_local(r, 'UserAuthentication') },
pass: ->(v) { v == 1 },
explain: ->(v) { "UserAuthentication=#{v.inspect}; without NLA attackers reach the login screen pre-auth (BlueKeep class bugs)" }
},
{
id: 'security_layer_tls',
title: 'Security layer set to TLS',
severity: :fail,
fetch: ->(r) { policy_or_local(r, 'SecurityLayer') },
pass: ->(v) { v == 2 },
explain: ->(v) { "SecurityLayer=#{v.inspect}; 0=RDP-native, 1=negotiate, 2=TLS/SSL. Require TLS" }
},
{
id: 'encryption_high',
title: 'Encryption level High or FIPS',
severity: :fail,
fetch: ->(r) { policy_or_local(r, 'MinEncryptionLevel') },
pass: ->(v) { [3, 4].include?(v) },
explain: ->(v) { "MinEncryptionLevel=#{v.inspect}; 1=Low 2=Client-compatible 3=High 4=FIPS" }
},
{
id: 'idle_timeout',
title: 'Idle session timeout configured',
severity: :warn,
fetch: ->(r) { policy_or_local(r, 'MaxIdleTime') },
pass: ->(v) { v.is_a?(Integer) && v.positive? && v <= 15 * 60 * 1000 },
explain: ->(v) { "MaxIdleTime=#{v.inspect} ms; set <= 900000 (15 min) so abandoned sessions cannot be hijacked" }
},
{
id: 'disconnect_timeout',
title: 'Disconnected session timeout configured',
severity: :warn,
fetch: ->(r) { policy_or_local(r, 'MaxDisconnectionTime') },
pass: ->(v) { v.is_a?(Integer) && v.positive? },
explain: ->(v) { "MaxDisconnectionTime=#{v.inspect}; disconnected sessions linger forever and hold licences/memory" }
},
{
id: 'clipboard_redirect',
title: 'Clipboard redirection disabled',
severity: :warn,
fetch: ->(r) { policy_or_local(r, 'fDisableClip') },
pass: ->(v) { v == 1 },
explain: ->(v) { "fDisableClip=#{v.inspect}; clipboard is a common exfil path for jump hosts" }
},
{
id: 'drive_redirect',
title: 'Drive redirection disabled',
severity: :warn,
fetch: ->(r) { policy_or_local(r, 'fDisableCdm') },
pass: ->(v) { v == 1 },
explain: ->(v) { "fDisableCdm=#{v.inspect}; mapped client drives let malware hop across the session" }
},
{
id: 'prompt_for_password',
title: 'Always prompt for password on connect',
severity: :warn,
fetch: ->(r) { policy_or_local(r, 'fPromptForPassword') },
pass: ->(v) { v == 1 },
explain: ->(v) { "fPromptForPassword=#{v.inspect}; prevents saved-credential auto-logon from stolen .rdp files" }
},
{
id: 'port_nonstandard',
title: 'Listening port (informational)',
severity: :info,
fetch: ->(r) { r.read('HKLM', RDP_TCP, 'PortNumber') },
pass: ->(v) { v != 3389 },
explain: ->(v) { "PortNumber=#{v.inspect}; 3389 is scanned constantly. Changing it is obscurity, not security, but cuts log noise" }
}
].freeze
# Firewall rule check is different in shape: we scan every rule string under
# FirewallRules for the built-in RDP rules and look at its RA4= (remote
# address) scope. "RA4=*"/absent means the whole internet may connect.
def firewall_scope(reg)
rules = reg.read('HKLM', FW_RULES, '__ALL__') # FixtureReader convenience
if rules.nil? && defined?(Win32::Registry)
rules = {}
Win32::Registry::HKEY_LOCAL_MACHINE.open(FW_RULES, Win32::Registry::KEY_READ | 0x0100) do |k|
k.each_value { |name, _type, data| rules[name] = data }
end
end
return nil if rules.nil?
rdp = rules.select { |name, data| name =~ /RemoteDesktop/i && data.include?('Active=TRUE') && data.include?('Dir=In') }
rdp.map do |name, data|
scope = data[/RA4=([^|]+)/, 1] || '*'
# A rule can list Profile= several times (Domain|Private|Public); no
# Profile= token at all means it applies to every profile.
profiles = data.scan(/Profile=([^|]+)/).flatten
profiles = ['Any'] if profiles.empty?
{ rule: name, profiles: profiles, remote_scope: scope }
end
end
# ----------------------------------------------------------------------------
# Runner
# ----------------------------------------------------------------------------
class RdpAudit
def initialize(reader)
@reader = reader
end
def run
results = CHECKS.map do |c|
value = c[:fetch].call(@reader)
ok = c[:pass].call(value)
status = ok ? 'PASS' : (c[:severity] == :info ? 'INFO' : c[:severity].to_s.upcase)
{ id: c[:id], title: c[:title], value: value, status: status, detail: ok ? nil : c[:explain].call(value) }
end
fw = firewall_scope(@reader)
unless fw.nil?
open_rules = fw.select { |r| r[:remote_scope] == '*' && r[:profiles].any? { |p| p =~ /Public|Any/i } }
results << {
id: 'firewall_scope', title: 'Firewall RDP rule limited to trusted subnets',
value: fw, status: open_rules.empty? ? 'PASS' : 'FAIL',
detail: open_rules.empty? ? nil : "#{open_rules.size} inbound RDP rule(s) allow any remote address on Public/Any profile"
}
end
# If RDP is off entirely, everything else is moot: downgrade to INFO.
if results.first[:value] == 1
results.each { |r| r[:status] = 'INFO' if r[:status] != 'PASS' }
end
results
end
end
def overall(results)
return 2 if results.any? { |r| r[:status] == 'FAIL' }
return 1 if results.any? { |r| r[:status] == 'WARN' }
0
end
def print_table(results, host)
puts "RDP hardening audit host=#{host}"
puts '=' * 78
results.each do |r|
mark = { 'PASS' => '[ OK ]', 'WARN' => '[WARN]', 'FAIL' => '[FAIL]', 'INFO' => '[INFO]' }[r[:status]]
puts format('%s %-46s %s', mark, r[:title], r[:value].is_a?(Array) ? "#{r[:value].size} rule(s)" : r[:value].inspect)
puts " -> #{r[:detail]}" if r[:detail]
end
puts '=' * 78
counts = results.group_by { |r| r[:status] }.transform_values(&:size)
puts "summary: #{counts.map { |k, v| "#{k}=#{v}" }.join(' ')}"
end
if __FILE__ == $PROGRAM_NAME
opts = { json: false, fixture: nil }
OptionParser.new do |o|
o.banner = 'Usage: win_rdp_hardening_audit.rb [--json] [--fixture FILE.json]'
o.on('--json', 'JSON output') { opts[:json] = true }
o.on('--fixture FILE', 'Read registry values from a JSON fixture (testing)') { |f| opts[:fixture] = f }
end.parse!
reader = opts[:fixture] ? FixtureReader.new(opts[:fixture]) : RegistryReader.new
host = ENV['COMPUTERNAME'] || (`hostname`.strip rescue 'unknown')
results = RdpAudit.new(reader).run
if opts[:json]
puts JSON.pretty_generate(host: host, generated: Time.now.utc, exit_code: overall(results), checks: results)
else
print_table(results, host)
end
exit overall(results)
end
Two readers, one interface. RegistryReader uses Win32::Registry and opens keys with KEY_READ | KEY_WOW64_64KEY so a 32-bit Ruby still sees the 64-bit hive. FixtureReader loads JSON of the same hive/key/value shape. The audit only calls #read, so it cannot tell them apart — which is exactly how the output tab was generated on Linux.
GPO precedence. Windows applies HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services over the local RDP-Tcp key. policy_or_local checks the policy hive first and falls back only when the value is absent, so the audit reports the setting that is actually in effect.
Firewall rules are strings. Each rule under FirewallPolicy\FirewallRules is a pipe-delimited string like Action=Allow|Active=TRUE|Dir=In|Profile=Public|RA4=10.0.0.0/8. The audit selects active inbound RemoteDesktop* rules and flags any that have no RA4= scope on a Public or Any profile.
$ ruby win_rdp_hardening_audit.rb --fixture fixture_default_windows.json
RDP hardening audit host=claude
==============================================================================
[INFO] Remote Desktop enabled 0
-> RDP is ENABLED; remaining checks matter
[FAIL] Network Level Authentication required 0
-> UserAuthentication=0; without NLA attackers reach the login screen pre-auth (BlueKeep class bugs)
[FAIL] Security layer set to TLS 1
-> SecurityLayer=1; 0=RDP-native, 1=negotiate, 2=TLS/SSL. Require TLS
[FAIL] Encryption level High or FIPS 2
-> MinEncryptionLevel=2; 1=Low 2=Client-compatible 3=High 4=FIPS
[WARN] Idle session timeout configured 0
-> MaxIdleTime=0 ms; set <= 900000 (15 min) so abandoned sessions cannot be hijacked
[WARN] Disconnected session timeout configured nil
-> MaxDisconnectionTime=nil; disconnected sessions linger forever and hold licences/memory
[WARN] Clipboard redirection disabled 0
-> fDisableClip=0; clipboard is a common exfil path for jump hosts
[WARN] Drive redirection disabled 0
-> fDisableCdm=0; mapped client drives let malware hop across the session
[WARN] Always prompt for password on connect 0
-> fPromptForPassword=0; prevents saved-credential auto-logon from stolen .rdp files
[INFO] Listening port (informational) 3389
-> PortNumber=3389; 3389 is scanned constantly. Changing it is obscurity, not security, but cuts log noise
[FAIL] Firewall RDP rule limited to trusted subnets 1 rule(s)
-> 1 inbound RDP rule(s) allow any remote address on Public/Any profile
==============================================================================
summary: INFO=2 FAIL=4 WARN=5
exit=2
$ ruby win_rdp_hardening_audit.rb --fixture fixture_hardened.json
RDP hardening audit host=claude
==============================================================================
[INFO] Remote Desktop enabled 0
-> RDP is ENABLED; remaining checks matter
[ OK ] Network Level Authentication required 1
[ OK ] Security layer set to TLS 2
[ OK ] Encryption level High or FIPS 4
[ OK ] Idle session timeout configured 900000
[ OK ] Disconnected session timeout configured 3600000
[ OK ] Clipboard redirection disabled 1
[ OK ] Drive redirection disabled 1
[ OK ] Always prompt for password on connect 1
[INFO] Listening port (informational) 3389
-> PortNumber=3389; 3389 is scanned constantly. Changing it is obscurity, not security, but cuts log noise
[ OK ] Firewall RDP rule limited to trusted subnets 1 rule(s)
==============================================================================
summary: INFO=2 PASS=9
exit=0
The problem this solves
Remote Desktop is essential and dangerous in equal measure. Microsoft’s own incident data and every ransomware retrospective of the last five years agree: internet-facing RDP without Network Level Authentication is the front door. The fixes are well known — require NLA, force the TLS security layer, set high encryption, time out idle and disconnected sessions, disable clipboard and drive redirection, and scope the firewall rule to management subnets — but they are spread across three registry locations and nobody wants to click through gpedit.msc on 200 hosts.
This tutorial builds win_rdp_hardening_audit.rb, a Ruby script that reads those settings using only win32/registry (bundled with the RubyInstaller builds — no gems) and produces a graded report. The interesting engineering detail is the Group Policy precedence: if a value exists under HKLM\SOFTWARE\Policies\...\Terminal Services it wins over the local WinStations\RDP-Tcp value, and an audit that ignores this will give you false reassurance.
Because the registry API does not exist on Linux, the script separates reading from judging. A FixtureReader loads a JSON snapshot of the same shape, which is how every check was tested in the Linux sandbox for this article. The same mechanism lets you unit-test the audit in CI and replay a snapshot exported from a production host.
Prerequisites
- Ruby 2.7+ on Windows (RubyInstaller).
win32/registryships with Ruby; no gems needed. - An elevated prompt is recommended: most keys are world-readable, but
FirewallRulesand some policy keys can be restricted. - For testing on any OS:
--fixture fixture_default_windows.jsonorfixture_hardened.jsonfrom the repo. Both are included.
The complete script
Reference copy of the whole script (the widget above has the same code with a copy button).
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_rdp_hardening_audit.rb -- audit Remote Desktop (RDP) hardening on a
# Windows host using only Ruby's bundled win32/registry library.
#
# Exposed RDP is the #1 initial-access vector for ransomware crews, and the
# settings that matter are scattered across three registry hives. This script
# reads each one, scores it against a baseline (CIS / Microsoft security
# baseline defaults), and prints a pass/fail table plus a JSON blob you can
# ship to your SIEM.
#
# Checks:
# * RDP enabled at all? (fDenyTSConnections)
# * Network Level Authentication on? (UserAuthentication)
# * TLS required for the RDP transport? (SecurityLayer)
# * Encryption level High/FIPS? (MinEncryptionLevel)
# * Listening port changed from 3389? (PortNumber -- informational)
# * Idle/disconnect session timeouts? (MaxIdleTime / MaxDisconnectionTime)
# * Clipboard / drive redirection off? (fDisableClip / fDisableCdm)
# * Windows Firewall RDP rule scope (via Windows Firewall registry)
# * Restrict local admin RDP via policy? (fPromptForPassword)
#
# Usage (on Windows, elevated prompt recommended):
# ruby win_rdp_hardening_audit.rb # table
# ruby win_rdp_hardening_audit.rb --json # JSON
# ruby win_rdp_hardening_audit.rb --fixture rdp_fixture.json # test anywhere
#
# Exit codes: 0 = all pass, 1 = warnings only, 2 = at least one FAIL.
require 'json'
require 'optparse'
# ----------------------------------------------------------------------------
# Registry access layer.
#
# RegistryReader talks to the real registry through win32/registry (bundled
# with the RubyInstaller builds). FixtureReader loads a JSON file of the same
# shape so the audit logic can be tested on Linux/macOS or in CI. The audit
# only ever calls #read(hive, key, value) so the two are interchangeable.
# ----------------------------------------------------------------------------
class RegistryReader
def initialize
require 'win32/registry'
@hives = {
'HKLM' => Win32::Registry::HKEY_LOCAL_MACHINE,
'HKCU' => Win32::Registry::HKEY_CURRENT_USER
}
end
# Returns the value, or nil if the key/value does not exist.
def read(hive, key, value)
# KEY_READ | KEY_WOW64_64KEY so a 32-bit Ruby still sees the 64-bit view.
access = Win32::Registry::KEY_READ | 0x0100
@hives.fetch(hive).open(key, access) { |reg| reg[value] }
rescue Win32::Registry::Error
nil
end
end
class FixtureReader
def initialize(path)
@data = JSON.parse(File.read(path))
end
def read(hive, key, value)
@data.dig(hive, key, value)
end
end
# ----------------------------------------------------------------------------
# The checks. Each is a hash describing where the value lives, what "good"
# looks like, and how to explain a failure to a human.
# ----------------------------------------------------------------------------
TS = 'SYSTEM\CurrentControlSet\Control\Terminal Server'
RDP_TCP = "#{TS}\\WinStations\\RDP-Tcp"
POLICY = 'SOFTWARE\Policies\Microsoft\Windows NT\Terminal Services'
FW_RULES = 'SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\FirewallRules'
# A value can be set by Group Policy (POLICY hive) which overrides the local
# WinStations setting. We check policy first, then fall back to the local key.
def policy_or_local(reg, value)
v = reg.read('HKLM', POLICY, value)
v.nil? ? reg.read('HKLM', RDP_TCP, value) : v
end
CHECKS = [
{
id: 'rdp_enabled',
title: 'Remote Desktop enabled',
severity: :info,
fetch: ->(r) { r.read('HKLM', TS, 'fDenyTSConnections') },
pass: ->(v) { v == 1 },
explain: ->(v) { v == 1 ? 'RDP disabled (fDenyTSConnections=1)' : 'RDP is ENABLED; remaining checks matter' }
},
{
id: 'nla_required',
title: 'Network Level Authentication required',
severity: :fail,
fetch: ->(r) { policy_or_local(r, 'UserAuthentication') },
pass: ->(v) { v == 1 },
explain: ->(v) { "UserAuthentication=#{v.inspect}; without NLA attackers reach the login screen pre-auth (BlueKeep class bugs)" }
},
{
id: 'security_layer_tls',
title: 'Security layer set to TLS',
severity: :fail,
fetch: ->(r) { policy_or_local(r, 'SecurityLayer') },
pass: ->(v) { v == 2 },
explain: ->(v) { "SecurityLayer=#{v.inspect}; 0=RDP-native, 1=negotiate, 2=TLS/SSL. Require TLS" }
},
{
id: 'encryption_high',
title: 'Encryption level High or FIPS',
severity: :fail,
fetch: ->(r) { policy_or_local(r, 'MinEncryptionLevel') },
pass: ->(v) { [3, 4].include?(v) },
explain: ->(v) { "MinEncryptionLevel=#{v.inspect}; 1=Low 2=Client-compatible 3=High 4=FIPS" }
},
{
id: 'idle_timeout',
title: 'Idle session timeout configured',
severity: :warn,
fetch: ->(r) { policy_or_local(r, 'MaxIdleTime') },
pass: ->(v) { v.is_a?(Integer) && v.positive? && v <= 15 * 60 * 1000 },
explain: ->(v) { "MaxIdleTime=#{v.inspect} ms; set <= 900000 (15 min) so abandoned sessions cannot be hijacked" }
},
{
id: 'disconnect_timeout',
title: 'Disconnected session timeout configured',
severity: :warn,
fetch: ->(r) { policy_or_local(r, 'MaxDisconnectionTime') },
pass: ->(v) { v.is_a?(Integer) && v.positive? },
explain: ->(v) { "MaxDisconnectionTime=#{v.inspect}; disconnected sessions linger forever and hold licences/memory" }
},
{
id: 'clipboard_redirect',
title: 'Clipboard redirection disabled',
severity: :warn,
fetch: ->(r) { policy_or_local(r, 'fDisableClip') },
pass: ->(v) { v == 1 },
explain: ->(v) { "fDisableClip=#{v.inspect}; clipboard is a common exfil path for jump hosts" }
},
{
id: 'drive_redirect',
title: 'Drive redirection disabled',
severity: :warn,
fetch: ->(r) { policy_or_local(r, 'fDisableCdm') },
pass: ->(v) { v == 1 },
explain: ->(v) { "fDisableCdm=#{v.inspect}; mapped client drives let malware hop across the session" }
},
{
id: 'prompt_for_password',
title: 'Always prompt for password on connect',
severity: :warn,
fetch: ->(r) { policy_or_local(r, 'fPromptForPassword') },
pass: ->(v) { v == 1 },
explain: ->(v) { "fPromptForPassword=#{v.inspect}; prevents saved-credential auto-logon from stolen .rdp files" }
},
{
id: 'port_nonstandard',
title: 'Listening port (informational)',
severity: :info,
fetch: ->(r) { r.read('HKLM', RDP_TCP, 'PortNumber') },
pass: ->(v) { v != 3389 },
explain: ->(v) { "PortNumber=#{v.inspect}; 3389 is scanned constantly. Changing it is obscurity, not security, but cuts log noise" }
}
].freeze
# Firewall rule check is different in shape: we scan every rule string under
# FirewallRules for the built-in RDP rules and look at its RA4= (remote
# address) scope. "RA4=*"/absent means the whole internet may connect.
def firewall_scope(reg)
rules = reg.read('HKLM', FW_RULES, '__ALL__') # FixtureReader convenience
if rules.nil? && defined?(Win32::Registry)
rules = {}
Win32::Registry::HKEY_LOCAL_MACHINE.open(FW_RULES, Win32::Registry::KEY_READ | 0x0100) do |k|
k.each_value { |name, _type, data| rules[name] = data }
end
end
return nil if rules.nil?
rdp = rules.select { |name, data| name =~ /RemoteDesktop/i && data.include?('Active=TRUE') && data.include?('Dir=In') }
rdp.map do |name, data|
scope = data[/RA4=([^|]+)/, 1] || '*'
# A rule can list Profile= several times (Domain|Private|Public); no
# Profile= token at all means it applies to every profile.
profiles = data.scan(/Profile=([^|]+)/).flatten
profiles = ['Any'] if profiles.empty?
{ rule: name, profiles: profiles, remote_scope: scope }
end
end
# ----------------------------------------------------------------------------
# Runner
# ----------------------------------------------------------------------------
class RdpAudit
def initialize(reader)
@reader = reader
end
def run
results = CHECKS.map do |c|
value = c[:fetch].call(@reader)
ok = c[:pass].call(value)
status = ok ? 'PASS' : (c[:severity] == :info ? 'INFO' : c[:severity].to_s.upcase)
{ id: c[:id], title: c[:title], value: value, status: status, detail: ok ? nil : c[:explain].call(value) }
end
fw = firewall_scope(@reader)
unless fw.nil?
open_rules = fw.select { |r| r[:remote_scope] == '*' && r[:profiles].any? { |p| p =~ /Public|Any/i } }
results << {
id: 'firewall_scope', title: 'Firewall RDP rule limited to trusted subnets',
value: fw, status: open_rules.empty? ? 'PASS' : 'FAIL',
detail: open_rules.empty? ? nil : "#{open_rules.size} inbound RDP rule(s) allow any remote address on Public/Any profile"
}
end
# If RDP is off entirely, everything else is moot: downgrade to INFO.
if results.first[:value] == 1
results.each { |r| r[:status] = 'INFO' if r[:status] != 'PASS' }
end
results
end
end
def overall(results)
return 2 if results.any? { |r| r[:status] == 'FAIL' }
return 1 if results.any? { |r| r[:status] == 'WARN' }
0
end
def print_table(results, host)
puts "RDP hardening audit host=#{host}"
puts '=' * 78
results.each do |r|
mark = { 'PASS' => '[ OK ]', 'WARN' => '[WARN]', 'FAIL' => '[FAIL]', 'INFO' => '[INFO]' }[r[:status]]
puts format('%s %-46s %s', mark, r[:title], r[:value].is_a?(Array) ? "#{r[:value].size} rule(s)" : r[:value].inspect)
puts " -> #{r[:detail]}" if r[:detail]
end
puts '=' * 78
counts = results.group_by { |r| r[:status] }.transform_values(&:size)
puts "summary: #{counts.map { |k, v| "#{k}=#{v}" }.join(' ')}"
end
if __FILE__ == $PROGRAM_NAME
opts = { json: false, fixture: nil }
OptionParser.new do |o|
o.banner = 'Usage: win_rdp_hardening_audit.rb [--json] [--fixture FILE.json]'
o.on('--json', 'JSON output') { opts[:json] = true }
o.on('--fixture FILE', 'Read registry values from a JSON fixture (testing)') { |f| opts[:fixture] = f }
end.parse!
reader = opts[:fixture] ? FixtureReader.new(opts[:fixture]) : RegistryReader.new
host = ENV['COMPUTERNAME'] || (`hostname`.strip rescue 'unknown')
results = RdpAudit.new(reader).run
if opts[:json]
puts JSON.pretty_generate(host: host, generated: Time.now.utc, exit_code: overall(results), checks: results)
else
print_table(results, host)
end
exit overall(results)
end
How it works, step by step
The reader abstraction
RegistryReader#read(hive, key, value) opens the key under HKLM or HKCU with KEY_READ | 0x0100 (KEY_WOW64_64KEY) and returns the value, or nil for any Win32::Registry::Error — a missing key and a missing value look the same to the audit, which is what we want. FixtureReader#read is a Hash#dig over parsed JSON. The require 'win32/registry' lives inside the class initializer so the file loads on Linux.
Where the values live
TS is SYSTEM\CurrentControlSet\Control\Terminal Server, home of fDenyTSConnections. RDP_TCP is its WinStations\RDP-Tcp subkey holding UserAuthentication, SecurityLayer, MinEncryptionLevel and PortNumber. POLICY is the Group Policy mirror. FW_RULES is where Windows Firewall stores every rule as a single string.
The check table
CHECKS is an array of hashes: an id, a title, a severity (:fail, :warn or :info), a fetch lambda, a pass predicate and an explain lambda that turns the raw value into advice. Adding a check is adding one hash. The thresholds mirror the CIS Microsoft Windows Server benchmark section on Remote Desktop Services: NLA on, SecurityLayer 2 (TLS), MinEncryptionLevel 3 or 4, idle timeout at most 15 minutes.
Group Policy precedence
policy_or_local reads the policy hive first and only falls back to RDP_TCP when the policy value is nil. This is exactly how the Terminal Services service resolves settings, and it is the difference between ‘the GUI checkbox says NLA is on’ and ‘NLA is actually on’.
Parsing firewall rules
firewall_scope enumerates FirewallRules (via each_value on Windows, or the __ALL__ convenience key in a fixture) and selects names matching RemoteDesktop that are Active=TRUE and Dir=In. It collects every Profile= token — a rule can list Domain, Private and Public — and reads RA4=. No RA4 means any remote address. A rule that is unscoped on Public or Any fails.
Grading and output
RdpAudit#run maps each check to a result hash. If RDP is disabled entirely (fDenyTSConnections=1) every non-PASS is downgraded to INFO, because the other settings are moot. overall returns 2 on any FAIL, 1 on any WARN, else 0. Text mode prints a bracketed status table; --json emits the same structure with host and timestamp.
Example output
Two fixtures: a fresh Windows install with defaults (RDP on, NLA off, negotiate security layer, wide-open firewall rule) and a host hardened by Group Policy. The script ran on Linux via --fixture; the registry reader path uses the same #read interface and was verified against the win32/registry API documentation but not executed in this sandbox.
When it does not behave
cannot load such file -- win32/registryon Windows. You are on a non-RubyInstaller build (e.g. a Linux-style MSYS build) — install Ruby from rubyinstaller.org. On Linux/macOS this is expected; use--fixture.- All checks show
nil. Either the key paths differ (older Server 2008 layouts) or you are running a 32-bit Ruby without the WOW64 flag. The script already passesKEY_WOW64_64KEY; verify withreg query "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp". - Firewall check shows 0 rules. The RDP rules are named
RemoteDesktop-UserMode-In-TCPetc. If the rule was created manually with a different name, extend the regex infirewall_scope. - Access denied on FirewallRules. Run from an elevated prompt.
- Honesty note: the Windows registry code path could not be executed in the Linux sandbox used to test this article. The audit logic was exercised end-to-end with the JSON fixtures; the
RegistryReaderclass follows the documentedWin32::Registry#open/#[]API. Please report any Windows-specific issue on the GitHub repo.
Where to take it next
- Add remediation: a
--fixflag that writes the hardened values withreg.write_i(after a confirmation prompt) and a--fix-dry-runthat prints the equivalentreg addcommands. - Run it fleet-wide with
winrmor thenet-sshgem against OpenSSH-for-Windows hosts and merge the JSON into one CSV. - Add checks for
RD Gatewayenforcement,Restricted Adminmode (DisableRestrictedAdmin) and theRemote Desktop Usersgroup membership via WMI. - Export a fixture from each production host on a schedule and diff it against the previous one — configuration drift detection for free.
- Emit results as Windows Event Log entries (
win32-eventloggem) so your existing SIEM forwarder picks them up.