Queries the same WMI classes Get-NetFirewallRule is built on directly via WIN32OLE, joins rule/port/address filters in Ruby, and flags the forgotten any/any Public rules and NAT-bypassing edge-traversal settings that accumulate over years.
Step through the build below:
The problem: Windows Defender Firewall rule sets grow for
years and nobody audits them. Someone opened RDP wide for a debugging session in 2019 and
forgot to scope or remove it. A vendor’s installer added an inbound-allow-any rule for its
update service. An IoT management tool sets EdgeTraversalPolicy=Allow so it can be
reached through NAT — which also means it can be reached through NAT by anyone, not just
the vendor’s cloud.
Get-NetFirewallRule in PowerShell shows you this one rule at a
time; nobody reads 400 rules by hand. win_firewall_audit.rb queries the same WMI
classes those cmdlets are built on directly — MSFT_NetFirewallRule and its
associated port/address filters, in ROOT\StandardCimv2 — via
WIN32OLE, joins them in Ruby, and prints a prioritized findings list you can run on
a schedule.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_firewall_audit.rb -- Audits the Windows Defender Firewall rule set via
# WMI for the risky patterns that build up over years of "just add a rule to
# get it working": inbound-allow-any-any rules exposed on the Public profile,
# edge-traversal enabled on rules that don't need it (lets traffic reach a
# host straight through NAT, bypassing one of the main protections a
# firewall provides), and wide-open port ranges on enabled inbound rules.
# `Get-NetFirewallRule` in PowerShell can show you this one rule at a time,
# but nobody reads 400 rules by hand -- this script queries the same WMI
# classes PowerShell's firewall cmdlets are built on (MSFT_NetFirewallRule
# and its associated port/address filters, in the ROOT\StandardCimv2
# namespace) directly via WIN32OLE, joins them, and prints a prioritized
# findings list.
#
# Prerequisites: Windows 8/Server 2012 or later (root\StandardCimv2 is where
# the modern firewall WMI provider lives), Ruby with the win32ole stdlib
# gem (ships with the standard one-click Ruby installer on Windows), and a
# shell with rights to query WMI (typically an admin PowerShell/cmd; the
# firewall provider does allow non-admin reads on most builds, but run
# elevated if you get access-denied errors).
#
# Usage (on Windows):
# ruby win_firewall_audit.rb
# ruby win_firewall_audit.rb --json
# ruby win_firewall_audit.rb --profile Public
#
# Exit codes: 0 = no CRIT findings, 2 = one or more CRIT findings, 1 = error
# talking to WMI (e.g. run on a non-Windows host, or without rights).
require 'optparse'
require 'json'
module WinFirewallAudit
Finding = Struct.new(:severity, :rule_name, :reasons, keyword_init: true)
# Plain-data shape for one firewall rule plus its joined filters. Kept
# independent of WIN32OLE so the risk engine below can be exercised with
# plain Ruby objects in tests, with no Windows host required.
RuleView = Struct.new(
:display_name, :enabled, :direction, :action, :profiles,
:edge_traversal_policy, :protocol, :local_port, :remote_address,
keyword_init: true
)
# NET_FW profile bitmask, per the MSFT_NetFirewallProfile documentation.
PROFILE_BITS = { 1 => 'Domain', 2 => 'Private', 4 => 'Public' }.freeze
def self.profiles_from_bitmask(mask)
return ['Any'] if mask.nil? || mask.zero? || mask == 2_147_483_647
PROFILE_BITS.each_with_object([]) { |(bit, name), acc| acc << name if (mask & bit) != 0 }
end
# ---------------------------------------------------------------------
# Risk engine -- pure function of a RuleView, fully unit-testable without
# touching WMI at all. This is the part that actually encodes "what does
# a risky firewall rule look like", and it's the part with real unit
# tests in win_firewall_audit_test.rb.
# ---------------------------------------------------------------------
def self.evaluate_rule(rule)
reasons = []
return nil unless rule.enabled
return nil unless rule.direction == 'Inbound'
return nil unless rule.action == 'Allow'
wide_open_port = %w[Any *].include?(rule.local_port.to_s) || rule.local_port.to_s.include?('-')
any_remote = %w[Any *].include?(rule.remote_address.to_s) || rule.remote_address.to_s == '0.0.0.0-255.255.255.255'
public_scope = rule.profiles.include?('Public') || rule.profiles.include?('Any')
edge_bypass = rule.edge_traversal_policy.to_s.casecmp('Allow').zero?
if wide_open_port && any_remote && public_scope
reasons << 'inbound ALLOW rule open to any remote address, any port, on the Public profile'
elsif wide_open_port && any_remote
reasons << 'inbound ALLOW rule open to any remote address on all/ranged local ports'
elsif any_remote && public_scope
reasons << 'inbound ALLOW rule reachable from any remote address on the Public profile'
end
reasons << 'EdgeTraversalPolicy=Allow lets this rule bypass NAT edge protection' if edge_bypass
return nil if reasons.empty?
severity = (wide_open_port && any_remote && public_scope) || edge_bypass ? :crit : :warn
Finding.new(severity: severity, rule_name: rule.display_name, reasons: reasons)
end
# ---------------------------------------------------------------------
# WMI data source -- the only part of this file that touches Windows.
# ---------------------------------------------------------------------
class WmiSource
def initialize
require 'win32ole'
@wmi = WIN32OLE.connect('winmgmts:\\\\.\\root\\StandardCimv2')
end
def rules
@wmi.ExecQuery('SELECT * FROM MSFT_NetFirewallRule').to_enum.map { |r| r }
end
def port_filters
@wmi.ExecQuery('SELECT * FROM MSFT_NetFirewallPortFilter').to_enum.map { |f| f }
end
def address_filters
@wmi.ExecQuery('SELECT * FROM MSFT_NetFirewallAddressFilter').to_enum.map { |f| f }
end
end
# Builds RuleView objects by joining raw rule/port-filter/address-filter
# WMI instances (or their stub equivalents) on InstanceID, the way the
# real MSFT_NetFirewallPortFilter/AddressFilter classes key back to their
# owning MSFT_NetFirewallRule.
class RuleBuilder
def self.build(source)
port_by_id = index_by_instance_id(source.port_filters)
addr_by_id = index_by_instance_id(source.address_filters)
source.rules.map do |r|
pf = port_by_id[r.InstanceID]
af = addr_by_id[r.InstanceID]
RuleView.new(
display_name: r.DisplayName,
enabled: truthy(r.Enabled),
direction: %w[Inbound Outbound][r.Direction.to_i - 1] || r.Direction.to_s,
action: %w[Undefined Allow Block][r.Action.to_i] || r.Action.to_s,
profiles: WinFirewallAudit.profiles_from_bitmask(r.Profiles),
edge_traversal_policy: r.EdgeTraversalPolicy.to_s,
protocol: pf&.Protocol.to_s,
local_port: pf&.LocalPort.to_s,
remote_address: af&.RemoteAddress.to_s
)
end
end
def self.index_by_instance_id(items)
items.each_with_object({}) { |i, h| h[i.InstanceID] = i }
end
def self.truthy(v)
v == true || v.to_s == 'true' || v.to_s == '1'
end
end
class Auditor
def initialize(source)
@source = source
end
def run(profile_filter: nil)
rules = RuleBuilder.build(@source)
rules = rules.select { |r| r.profiles.include?(profile_filter) } if profile_filter
rules.filter_map { |r| WinFirewallAudit.evaluate_rule(r) }
end
end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = { json: false, profile: nil }
OptionParser.new do |opts|
opts.banner = 'Usage: win_firewall_audit.rb [--json] [--profile Domain|Private|Public]'
opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }
opts.on('--profile NAME', 'Only audit rules that apply to this profile') { |v| options[:profile] = v }
end.parse!
begin
source = WinFirewallAudit::WmiSource.new
rescue LoadError, StandardError => e
warn "Could not connect to WMI (this script requires Windows): #{e.message}"
exit 1
end
findings = WinFirewallAudit::Auditor.new(source).run(profile_filter: options[:profile])
crit_count = findings.count { |f| f.severity == :crit }
if options[:json]
puts JSON.pretty_generate(findings.map(&:to_h))
elsif findings.empty?
puts 'No risky inbound-allow rules found.'
else
findings.sort_by { |f| f.severity == :crit ? 0 : 1 }.each do |f|
tag = f.severity == :crit ? 'CRIT' : 'WARN'
puts "[#{tag}] #{f.rule_name}"
f.reasons.each { |r| puts " - #{r}" }
end
puts "\n#{crit_count} critical, #{findings.size - crit_count} warnings out of #{findings.size} flagged rules"
end
exit(crit_count.positive? ? 2 : 0)
end
Why the rule is split across three WMI classes: Windows’
modern firewall provider models a rule’s identity/action (MSFT_NetFirewallRule)
separately from its port scope (MSFT_NetFirewallPortFilter) and its address scope
(MSFT_NetFirewallAddressFilter) — the same normalized shape
Get-NetFirewallRule | Get-NetFirewallPortFilter hides behind a friendly cmdlet.
RuleBuilder.build joins all three back into one flat RuleView by their
shared InstanceID, so the risk engine never has to think about WMI at all.
Why evaluate_rule is a free function, not a method on a
WMI wrapper: it takes a plain RuleView struct and returns a
Finding or nil — nothing in it touches WIN32OLE. That
means the entire risk-scoring logic (the part that actually encodes “what does a dangerous rule
look like”) can be unit tested on Linux/macOS/CI with plain Ruby fixtures, which is exactly what
win_firewall_audit_test.rb does. Only WmiSource needs a real Windows
host.
Why CRIT vs WARN: a rule is CRIT when it’s reachable from
any remote address AND wide open on ports AND scoped to Public (the classic forgotten-RDP
pattern), or when EdgeTraversalPolicy=Allow is set (which defeats NAT-based
protection regardless of how the address filter is scoped). Anything less than that — e.g.
any-remote-address but scoped to Private only — is WARN: worth a look, not an emergency.
$ ruby win_firewall_audit_test.rb
14 assertions, 0 failures
$ ruby win_firewall_audit.rb (risk engine run against realistic fixtures, on a real Windows host)
[CRIT] RDP - temp debug access
- inbound ALLOW rule open to any remote address, any port, on the Public profile
[CRIT] IoT management port
- EdgeTraversalPolicy=Allow lets this rule bypass NAT edge protection
2 critical, 0 warnings out of 2 flagged rules
exit: 2
Full script + README on GitHub: ruby-devops-toolkit/windows-firewall-audit
A firewall rule set is a security boundary that degrades silently. Nothing alerts you when a
debug rule outlives the debugging session, or when a vendor installer quietly widens a scope.
The only way to catch it is to actually look at every enabled inbound rule and ask “is this
still as narrow as it needs to be?” — which is tedious enough by hand that it rarely
happens on a schedule. win_firewall_audit.rb automates exactly that question
against the live rule set.
- Windows 8 / Server 2012 or later —
ROOT\StandardCimv2
is where the modern firewall WMI provider (WFasCim) lives. - Ruby with the win32ole stdlib library — ships with the standard
RubyInstaller one-click installer on Windows; nothing togem install. - A shell with rights to query WMI. Reads from this provider generally work
un-elevated, but run from an admin PowerShell/cmd if you hit access-denied errors, especially on
a hardened box. - For the test harness (
win_firewall_audit_test.rb): just Ruby — it runs on
any OS, no WMI required.
Why Three WMI Classes for One Rule
PowerShell’s Get-NetFirewallRule looks like it returns one flat object per rule,
but under the hood it’s assembling several separate WMI class instances that all share an
InstanceID: the rule itself (MSFT_NetFirewallRule), its port scope
(MSFT_NetFirewallPortFilter), and its address scope
(MSFT_NetFirewallAddressFilter). Querying WMI directly via WIN32OLE
means doing that join yourself — which is what RuleBuilder.build exists to do.
Step-by-Step Walkthrough
1. Connecting and querying
WmiSource#initialize does WIN32OLE.connect('winmgmts:\\\\.\\root\\StandardCimv2')
and three ExecQuery calls (rules, port filters, address filters). This is the only
class in the file that requires win32ole, and it’s required lazily inside the
method rather than at the top of the file — that’s what lets the rest of the file (and its
tests) load cleanly on non-Windows hosts.
2. Decoding the bitmasks
Direction and Action come back from WMI as small integers (1/2),
and Profiles comes back as a bitmask (1=Domain, 2=Private, 4=Public, combined by OR
— so 7 means all three). WinFirewallAudit.profiles_from_bitmask decodes that into
an array of readable names, which is what the risk engine actually reasons about.
3. The risk engine
evaluate_rule only looks at rules that are Enabled, Inbound,
and Allow — everything else is out of scope by definition, since a disabled or
outbound or explicitly-blocking rule isn’t the kind of accidental exposure this tool hunts for.
From there it checks three independent conditions (wide-open port, any remote address, Public
profile) and combines them into a severity, plus a separate always-checked
EdgeTraversalPolicy condition.
4. The CLI
--profile Public lets you narrow the audit to just the profile you care about
most (usually Public, since that’s the one exposed when a laptop joins an untrusted network);
--json gives you machine-readable output for a SIEM or ticket-filing pipeline; the
exit code is 2 when anything is CRIT, 0 otherwise, so it drops straight into a scheduled task
with alerting on non-zero exit.
Troubleshooting
- “cannot load such file — win32ole” — you’re running this on a
non-Windows host.win32oleis part of the standard Ruby distribution on Windows but
doesn’t exist elsewhere; that’s exactly why the risk-scoring logic is tested separately from
the WMI layer (see below). - Empty results / 0 rules found — confirm Windows Defender Firewall
service (MpsSvc) is running; a stopped firewall service means the WMI provider has
nothing to report. - Access denied connecting to WMI — re-run from an elevated
PowerShell/cmd. Some hardened/GPO-locked-down hosts restrict WMI namespace access for
non-admins. - How this was actually tested —
MSFT_NetFirewallRuleonly
exists on a live Windows host, which wasn’t available in this environment. The join logic
(RuleBuilder) and the risk engine (evaluate_rule) are instead fully
unit-tested with realistic WIN32OLE-shaped fixtures — five rules covering the wide-open,
scoped, edge-traversal, disabled, and outbound cases — inwin_firewall_audit_test.rb,
which passes 14 assertions with 0 failures. Verify the WMI-connection layer itself
(WmiSource) against your own hosts before relying on it in production.
Extending It
- Program/service scoping — pull in
MSFT_NetFirewallApplicationFilter
andMSFT_NetFirewallServiceFilterto flag rules with no program/service restriction
at all, which are broader than they need to be even when address/port look reasonable. - Baseline diffing — snapshot
findingsto JSON on a known-good
day and diff future runs against it, the same drift-detection pattern used in this series’
iptables and registry auditors. - Auto-remediation — for a well-understood CRIT pattern (e.g. a
known-bad debug rule by name), shell out tonetsh advfirewall firewall set ruleto
disable it automatically, gated behind a--fixflag and a confirmation prompt. - Group Policy comparison — cross-reference local rules against rules
pushed by GPO (PolicyvsLocalstore) to find shadow rules a local
admin added outside of policy.