the shed // ruby x windows // security

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.

Get the code

Full script + README on GitHub: ruby-devops-toolkit/win-rdp-hardening-audit

Step through the build below:

win_rdp_hardening_audit.rb

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
01 // context

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.

Map of the three registry hives the audit reads, the two interchangeable readers, and the check runner

Three registry locations, one #read(hive, key, value) interface, swappable JSON fixture for CI.
02 // setup

Prerequisites

you will need
  • Ruby 2.7+ on Windows (RubyInstaller). win32/registry ships with Ruby; no gems needed.
  • An elevated prompt is recommended: most keys are world-readable, but FirewallRules and some policy keys can be restricted.
  • For testing on any OS: --fixture fixture_default_windows.json or fixture_hardened.json from the repo. Both are included.
03 // source

The complete script

Reference copy of the whole script (the widget above has the same code with a copy button).

win_rdp_hardening_audit.rbruby
#!/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
04 // walkthrough

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.

05 // run it

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.

win_rdp_hardening_audit.rb — sandbox run
$ 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
11
checks per host
3
registry hives
0
gems required
06 // troubleshooting

When it does not behave

common issues
  • cannot load such file -- win32/registry on 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 passes KEY_WOW64_64KEY; verify with reg query "HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp".
  • Firewall check shows 0 rules. The RDP rules are named RemoteDesktop-UserMode-In-TCP etc. If the rule was created manually with a different name, extend the regex in firewall_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 RegistryReader class follows the documented Win32::Registry#open/#[] API. Please report any Windows-specific issue on the GitHub repo.
A PASS here is not a pen test

This audit checks configuration, not exposure. Pair it with an external port scan and MFA on the RD Gateway. Changing PortNumber away from 3389 is noise reduction, not security.
07 // extend

Where to take it next

ideas
  • Add remediation: a --fix flag that writes the hardened values with reg.write_i (after a confirmation prompt) and a --fix-dry-run that prints the equivalent reg add commands.
  • Run it fleet-wide with winrm or the net-ssh gem against OpenSSH-for-Windows hosts and merge the JSON into one CSV.
  • Add checks for RD Gateway enforcement, Restricted Admin mode (DisableRestrictedAdmin) and the Remote Desktop Users group 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-eventlog gem) so your existing SIEM forwarder picks them up.