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:
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
Full script + README on GitHub: ruby-devops-toolkit/windows-firewall-audit
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
netshlabels are localised. - Offline:
netsh advfirewall firewall show rule name=all verbose > rules.txtandnetsh advfirewall show allprofiles state > profiles.txt.
Full code
#!/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
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.
What a run looks like
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-NetFirewallRuleinstead (see Extending). - Honest testing note:
netshonly 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 livenetshcall itself was not executed here.
Extending the script
- Switch to
Get-NetFirewallRule | ConvertTo-Jsonfor 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--fixflag.