the shed // ruby // windows

Windows Firewall rules pile up: RDP open to the world, a helper.exe in Downloads with an inbound hole. This Ruby script parses netsh output and surfaces the rules that matter.

Step through the build below:

firewall_audit.rb

Over a machine's life, installers and admins add inbound allow rules and nobody removes them. The result is RDP or SMB reachable from anywhere, or an executable in a user's Downloads folder with its own firewall exception. This script shells out to netsh advfirewall, parses the verbose rule dump into Ruby hashes, and applies a short set of risk checks. It also has an offline mode so you can audit exported text on any machine.

#!/usr/bin/env ruby
# frozen_string_literal: true
# firewall_audit.rb - audit Windows Firewall inbound-allow rules. Ruby 2.7+, stdlib only.
# Live:    ruby firewall_audit.rb            (runs `netsh advfirewall`, needs an English-locale Windows)
# Offline: ruby firewall_audit.rb --rules rules.txt --profiles profiles.txt
require 'optparse'
require 'json'
RISKY_PORTS = { '21' => 'FTP', '23' => 'Telnet', '135' => 'RPC', '445' => 'SMB',
                '3389' => 'RDP', '5985' => 'WinRM-HTTP', '5986' => 'WinRM-HTTPS' }.freeze
# Parse `netsh advfirewall firewall show rule name=all verbose` into an array of hashes.
# Each rule is a block of "Key:      Value" lines separated by a line of dashes.
def parse_rules(text)
  text.split(/^-{20,}\s*$/).filter_map do |block|
    rule = {}
    block.each_line do |l|
      k, v = l.strip.split(/:\s+/, 2)
      rule[k] = v if k && v && !k.empty?
    end
    rule if rule['Rule Name']
  end
end
# Parse `netsh advfirewall show allprofiles state` -> { "Domain" => "ON", ... }
def parse_profiles(text)
  prof = nil
  text.each_line.with_object({}) do |l, h|
    prof = Regexp.last_match(1) if l =~ /^(\w+) Profile Settings/
    h[prof] = Regexp.last_match(1).upcase if prof && l =~ /^State\s+(\w+)/
  end
end
def audit(rules, profiles)
  out = []
  profiles.each { |p, s| out << ['HIGH', "#{p} profile", 'firewall-off', 'profile state is OFF'] if s == 'OFF' }
  rules.each do |r|
    next unless r['Enabled'] == 'Yes' && r['Direction'] == 'In' && r['Action'] == 'Allow'
    name = r['Rule Name']
    remote_any = r['RemoteIP'].to_s.casecmp('Any').zero?
    ports = r['LocalPort'].to_s.split(',').map(&:strip)
    public_prof = r['Profiles'].to_s =~ /Public|Any|All/i
    risky = ports.select { |pt| RISKY_PORTS.key?(pt) }
    risky.each do |pt|
      sev = remote_any ? 'HIGH' : 'MEDIUM'
      out << [sev, name, 'risky-port', "#{RISKY_PORTS[pt]} (#{pt}) open to #{r['RemoteIP']} on #{r['Profiles']}"]
    end
    if remote_any && public_prof && r['Program'].to_s.casecmp('Any').zero? && r['LocalPort'].to_s.casecmp('Any').zero?
      out << ['HIGH', name, 'wide-open', 'any program, any port, any remote, Public profile']
    end
    if remote_any && r['Program'].to_s =~ /\\(Temp|Downloads|AppData|Users)\\/i
      out << ['MEDIUM', name, 'odd-program-path', "allows inbound for #{r['Program']}"]
    end
  end
  out.sort_by { |s, *| s == 'HIGH' ? 0 : 1 }
end
if $PROGRAM_NAME == __FILE__
  o = {}
  OptionParser.new do |p|
    p.on('--rules FILE') { |v| o[:rules] = v }
    p.on('--profiles FILE') { |v| o[:profiles] = v }
    p.on('-j', '--json') { o[:json] = true }
  end.parse!
  rt = o[:rules] ? File.read(o[:rules]) : `netsh advfirewall firewall show rule name=all verbose`
  pt = o[:profiles] ? File.read(o[:profiles]) : `netsh advfirewall show allprofiles state`
  rules = parse_rules(rt)
  findings = audit(rules, parse_profiles(pt))
  if o[:json]
    puts JSON.pretty_generate(findings.map { |s, n, k, d| { severity: s, rule: n, check: k, detail: d } })
  else
    puts "Parsed #{rules.size} rules"
    findings.each { |s, n, k, d| puts format('%-6s %-14s %-28s %s', s, k, n, d) }
    puts findings.empty? ? 'No findings.' : "#{findings.count { |f| f[0] == 'HIGH' }} high / #{findings.size} total"
  end
  exit(findings.any? { |f| f[0] == 'HIGH' } ? 1 : 0)
end

1. Parse the netsh dump. parse_rules splits on the dashed separator lines, then turns each Key: Value line into a hash entry.

2. Check profile state. parse_profiles reports any Domain/Private/Public profile whose State is OFF as HIGH.

3. Filter to what matters. Only rules that are Enabled, Direction In, Action Allow can expose the host, so everything else is skipped.

4. Apply risk checks. Known-dangerous ports (RDP 3389, SMB 445, Telnet 23, FTP 21, WinRM) are HIGH when RemoteIP is Any, MEDIUM otherwise. Rules allowing programs from Users, Downloads, Temp or AppData paths are flagged. Any-program/any-port/any-remote on the Public profile is HIGH.

5. Report and exit. Severity-sorted output, --json for pipelines, exit 1 if any HIGH.

$ test run (Linux sandbox)

Parsed 7 rules
HIGH   firewall-off   Public profile               profile state is OFF
HIGH   risky-port     Remote Desktop - User Mode (TCP-In) RDP (3389) open to Any on Domain,Private,Public
MEDIUM risky-port     File and Printer Sharing (SMB-In) SMB (445) open to LocalSubnet on Domain,Private
MEDIUM odd-program-path Dev Tool Helper              allows inbound for C:\Users\bob\Downloads\helper.exe
MEDIUM risky-port     Legacy Telnet                Telnet (23) open to 10.0.0.0/8 on Domain
2 high / 5 total
Get the code

Full script + README on GitHub: ruby-devops-toolkit/windows-firewall-audit

Audit Windows Firewall Inbound Rules with Ruby and netsh workflow diagram

Workflow: from netsh output to findings.
prerequisites

What you need

  • Ruby 2.7+ via RubyInstaller on Windows (tested on 3.3.6 on Linux for the offline mode).
  • Stdlib only. Live mode needs an elevated prompt and an English-locale Windows, because netsh labels are localised.
  • Offline: netsh advfirewall firewall show rule name=all verbose > rules.txt and netsh advfirewall show allprofiles state > profiles.txt.
the script

Full code

firewall_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
# firewall_audit.rb - audit Windows Firewall inbound-allow rules. Ruby 2.7+, stdlib only.
# Live:    ruby firewall_audit.rb            (runs `netsh advfirewall`, needs an English-locale Windows)
# Offline: ruby firewall_audit.rb --rules rules.txt --profiles profiles.txt
require 'optparse'
require 'json'
RISKY_PORTS = { '21' => 'FTP', '23' => 'Telnet', '135' => 'RPC', '445' => 'SMB',
                '3389' => 'RDP', '5985' => 'WinRM-HTTP', '5986' => 'WinRM-HTTPS' }.freeze
# Parse `netsh advfirewall firewall show rule name=all verbose` into an array of hashes.
# Each rule is a block of "Key:      Value" lines separated by a line of dashes.
def parse_rules(text)
  text.split(/^-{20,}\s*$/).filter_map do |block|
    rule = {}
    block.each_line do |l|
      k, v = l.strip.split(/:\s+/, 2)
      rule[k] = v if k && v && !k.empty?
    end
    rule if rule['Rule Name']
  end
end
# Parse `netsh advfirewall show allprofiles state` -> { "Domain" => "ON", ... }
def parse_profiles(text)
  prof = nil
  text.each_line.with_object({}) do |l, h|
    prof = Regexp.last_match(1) if l =~ /^(\w+) Profile Settings/
    h[prof] = Regexp.last_match(1).upcase if prof && l =~ /^State\s+(\w+)/
  end
end
def audit(rules, profiles)
  out = []
  profiles.each { |p, s| out << ['HIGH', "#{p} profile", 'firewall-off', 'profile state is OFF'] if s == 'OFF' }
  rules.each do |r|
    next unless r['Enabled'] == 'Yes' && r['Direction'] == 'In' && r['Action'] == 'Allow'
    name = r['Rule Name']
    remote_any = r['RemoteIP'].to_s.casecmp('Any').zero?
    ports = r['LocalPort'].to_s.split(',').map(&:strip)
    public_prof = r['Profiles'].to_s =~ /Public|Any|All/i
    risky = ports.select { |pt| RISKY_PORTS.key?(pt) }
    risky.each do |pt|
      sev = remote_any ? 'HIGH' : 'MEDIUM'
      out << [sev, name, 'risky-port', "#{RISKY_PORTS[pt]} (#{pt}) open to #{r['RemoteIP']} on #{r['Profiles']}"]
    end
    if remote_any && public_prof && r['Program'].to_s.casecmp('Any').zero? && r['LocalPort'].to_s.casecmp('Any').zero?
      out << ['HIGH', name, 'wide-open', 'any program, any port, any remote, Public profile']
    end
    if remote_any && r['Program'].to_s =~ /\\(Temp|Downloads|AppData|Users)\\/i
      out << ['MEDIUM', name, 'odd-program-path', "allows inbound for #{r['Program']}"]
    end
  end
  out.sort_by { |s, *| s == 'HIGH' ? 0 : 1 }
end
if $PROGRAM_NAME == __FILE__
  o = {}
  OptionParser.new do |p|
    p.on('--rules FILE') { |v| o[:rules] = v }
    p.on('--profiles FILE') { |v| o[:profiles] = v }
    p.on('-j', '--json') { o[:json] = true }
  end.parse!
  rt = o[:rules] ? File.read(o[:rules]) : `netsh advfirewall firewall show rule name=all verbose`
  pt = o[:profiles] ? File.read(o[:profiles]) : `netsh advfirewall show allprofiles state`
  rules = parse_rules(rt)
  findings = audit(rules, parse_profiles(pt))
  if o[:json]
    puts JSON.pretty_generate(findings.map { |s, n, k, d| { severity: s, rule: n, check: k, detail: d } })
  else
    puts "Parsed #{rules.size} rules"
    findings.each { |s, n, k, d| puts format('%-6s %-14s %-28s %s', s, k, n, d) }
    puts findings.empty? ? 'No findings.' : "#{findings.count { |f| f[0] == 'HIGH' }} high / #{findings.size} total"
  end
  exit(findings.any? { |f| f[0] == 'HIGH' } ? 1 : 0)
end
walkthrough

Step by step

Parse the netsh dump

parse_rules splits on the dashed separator lines, then turns each Key: Value line into a hash entry.

Check profile state

parse_profiles reports any Domain/Private/Public profile whose State is OFF as HIGH.

Filter to what matters

Only rules that are Enabled, Direction In, Action Allow can expose the host, so everything else is skipped.

Apply risk checks

Known-dangerous ports (RDP 3389, SMB 445, Telnet 23, FTP 21, WinRM) are HIGH when RemoteIP is Any, MEDIUM otherwise. Rules allowing programs from Users, Downloads, Temp or AppData paths are flagged. Any-program/any-port/any-remote on the Public profile is HIGH.

Report and exit

Severity-sorted output, --json for pipelines, exit 1 if any HIGH.

example output

What a run looks like

test run
Parsed 7 rules
HIGH firewall-off Public profile profile state is OFF
HIGH risky-port Remote Desktop – User Mode (TCP-In) RDP (3389) open to Any on Domain,Private,Public
MEDIUM risky-port File and Printer Sharing (SMB-In) SMB (445) open to LocalSubnet on Domain,Private
MEDIUM odd-program-path Dev Tool Helper allows inbound for C:\Users\bob\Downloads\helper.exe
MEDIUM risky-port Legacy Telnet Telnet (23) open to 10.0.0.0/8 on Domain
2 high / 5 total
troubleshooting

When it misbehaves

  • Access denied or empty output: use an elevated prompt.
  • Non-English Windows: netsh field names are translated, so parsing finds zero rules. Use PowerShell Get-NetFirewallRule instead (see Extending).
  • Honest testing note: netsh only exists on Windows. The parser and checks were verified in the Linux sandbox against a hand-built sample of netsh-format output (7 rules, 3 profiles); the live netsh call itself was not executed here.
next steps

Extending the script

  • Switch to Get-NetFirewallRule | ConvertTo-Json for locale-independent parsing.
  • Diff against a saved baseline to report only new rules.
  • Export findings to CSV for auditors.
  • Remove a flagged rule with netsh advfirewall firewall delete rule name=... behind a --fix flag.

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating