Shore Up
A clipboard-carrying inspector walking a row of gates, comparing each one against a framed master plan on the wall and ticking the gates that no longer match it.
WindowsSecurity

Audit Windows Firewall Rules and Report Drift From a Baseline

Ketan Aagja10 min read
No ratings yet

Before you run this

This guide builds two small PowerShell scripts. The first captures the current Windows Firewall ruleset to a CSV baseline. The second re-reads the live rules and reports which ones were added, removed, or changed since that baseline. Both scripts are read-only — they use Get-* cmdlets only and change no firewall rules, so running the audit cannot break connectivity.

  • Privileges: Run both scripts in an elevated PowerShell session (Run as administrator). Reading the full effective ruleset — and the netsh config backup mentioned below — is reliable only when elevated. The NetSecurity module they use ships with Windows; nothing to install.
  • Test first: Read the scripts before you run them, and run them once on a test VM or a spare workstation so you know what the baseline and drift CSVs look like before you point them at a production server.
  • The audit is safe; acting on the findings is not. Nothing here deletes or edits a rule. But once the report shows drift and you decide to remediate, remember that changing a host firewall rule for RDP, WinRM, or a listening service can cut you off from the box. Before you change anything:
    • Keep a second way in — a console/KVM/iLO session, Hyper-V console, or physical access — so a bad rule change doesn't strand you.
    • Back up the firewall config first with netsh advfirewall export "C:\FirewallAudit\firewall-backup.wfw" (elevated). Restore with netsh advfirewall import "C:\FirewallAudit\firewall-backup.wfw". That is your rollback path.
    • Make rule changes in a maintenance window, and re-baseline afterward so approved changes don't show as drift forever.

What I'm assuming

  • Windows Server 2022 or Windows 11, standalone or domain-joined.
  • Windows PowerShell 5.1 (the built-in one). PowerShell 7 also works; the cmdlets are identical.
  • The built-in NetSecurity module (Get-NetFirewallRule and friends).
  • A folder for the artefacts: C:\FirewallAudit. Change that path to suit — it's an obvious placeholder.

One thing worth knowing up front: firewall rules can come from the local store or be pushed by Group Policy. To audit the effective ruleset (local + GPO), I query the ActiveStore. That is the mainstream choice for drift detection. If you only want to track locally-defined rules, use the default PersistentStore instead.

Step 1 — Capture the baseline

Save this as Capture-FirewallBaseline.ps1. It flattens each rule plus its port, address, and program filters into one row so the report is human-readable.

#Requires -RunAsAdministrator
# Capture-FirewallBaseline.ps1 — read-only snapshot of the effective firewall ruleset

$baselinePath = "C:\FirewallAudit\baseline.csv"   # change to your path

# ActiveStore = the effective set (local rules + any pushed by GPO)
$rules = Get-NetFirewallRule -PolicyStore ActiveStore

$report = foreach ($rule in $rules) {
    # Each rule's details live in separate filter objects, fetched via the pipeline
    $port = $rule | Get-NetFirewallPortFilter
    $addr = $rule | Get-NetFirewallAddressFilter
    $app  = $rule | Get-NetFirewallApplicationFilter

    [pscustomobject]@{
        Name          = [string]$rule.Name          # cast to string so it compares
        DisplayName   = [string]$rule.DisplayName    # cleanly against CSV later
        Enabled       = [string]$rule.Enabled
        Direction     = [string]$rule.Direction
        Action        = [string]$rule.Action
        Profile       = [string]$rule.Profile
        Protocol      = [string]$port.Protocol
        LocalPort     = ($port.LocalPort  -join ',')
        RemotePort    = ($port.RemotePort -join ',')
        RemoteAddress = ($addr.RemoteAddress -join ',')
        Program       = [string]$app.Program
    }
}

$report | Sort-Object Name | Export-Csv -Path $baselinePath -NoTypeInformation
Write-Host "Baseline written to $baselinePath ($($report.Count) rules)"

A note on speed: piping each rule through three filter cmdlets means several CIM queries per rule, so on a box with many hundreds of rules this takes a minute or two. That's fine for a snapshot you take occasionally or on a schedule. Keep it simple rather than clever.

Run it once when the host is in a known-good state — ideally right after you've built and hardened it, or right after an approved change. That CSV is now your reference of record. Store it somewhere it won't be edited by accident (read-only share, source control, whatever you already use).

Step 2 — Compare live rules against the baseline

Save this as Compare-FirewallDrift.ps1. It re-captures the current state exactly the way the baseline was captured, then uses Compare-Object to find differences.

#Requires -RunAsAdministrator
# Compare-FirewallDrift.ps1 — read-only drift report against a stored baseline

$baselinePath = "C:\FirewallAudit\baseline.csv"
$driftPath    = "C:\FirewallAudit\drift-$(Get-Date -Format 'yyyyMMdd-HHmmss').csv"

if (-not (Test-Path $baselinePath)) {
    throw "Baseline not found at $baselinePath. Run Capture-FirewallBaseline.ps1 first."
}

$baseline = Import-Csv $baselinePath

# Re-capture current state using the SAME shape as the baseline
$rules = Get-NetFirewallRule -PolicyStore ActiveStore
$current = foreach ($rule in $rules) {
    $port = $rule | Get-NetFirewallPortFilter
    $addr = $rule | Get-NetFirewallAddressFilter
    $app  = $rule | Get-NetFirewallApplicationFilter
    [pscustomobject]@{
        Name          = [string]$rule.Name
        DisplayName   = [string]$rule.DisplayName
        Enabled       = [string]$rule.Enabled
        Direction     = [string]$rule.Direction
        Action        = [string]$rule.Action
        Profile       = [string]$rule.Profile
        Protocol      = [string]$port.Protocol
        LocalPort     = ($port.LocalPort  -join ',')
        RemotePort    = ($port.RemotePort -join ',')
        RemoteAddress = ($addr.RemoteAddress -join ',')
        Program       = [string]$app.Program
    }
}

# Compare on every field. => means present now but not in baseline (added/changed);
# <= means present in baseline but not now (removed/changed).
$props = 'Name','DisplayName','Enabled','Direction','Action','Profile',
         'Protocol','LocalPort','RemotePort','RemoteAddress','Program'

$diff = Compare-Object -ReferenceObject $baseline -DifferenceObject $current -Property $props

if ($diff) {
    $diff | Sort-Object Name | Export-Csv -Path $driftPath -NoTypeInformation
    Write-Warning "Drift detected: $($diff.Count) differing entries. See $driftPath"
} else {
    Write-Host "No drift. Current firewall rules match the baseline."
}

Because I capture and store every field as a string on both sides, Compare-Object doesn't throw false positives from enum-versus-text mismatches. A changed rule shows up as two lines — a <= (the old values) and a => (the new values) — which makes it obvious what moved.

Step 3 — Run it on a schedule (optional)

The mainstream way to run this unattended is a scheduled task under an account with local admin rights, action powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\FirewallAudit\Compare-FirewallDrift.ps1. Point it at a nightly or hourly trigger and drop the drift CSVs on a share you actually read. You can register the task from the Task Scheduler GUI or with Register-ScheduledTask; check Microsoft Learn for the exact Register-ScheduledTask / New-ScheduledTaskAction syntax before scripting it, as the trigger and principal objects have a few required pieces. Wiring alerting into email or a SIEM is beyond this guide's scope, but the CSV is the input you'd feed it.

Verify it actually works

Prove the detection end to end with a harmless, disabled test rule:

# 1. Baseline a clean state
.\Capture-FirewallBaseline.ps1

# 2. Confirm the CSV row count matches the live rule count
(Import-Csv C:\FirewallAudit\baseline.csv).Count
(Get-NetFirewallRule -PolicyStore ActiveStore).Count

# 3. Introduce a known change: a DISABLED test rule on an unused port
New-NetFirewallRule -DisplayName "AUDIT-TEST-RULE" -Direction Inbound `
    -Action Block -Protocol TCP -LocalPort 65000 -Enabled False

# 4. Re-run the comparison — AUDIT-TEST-RULE should appear as drift
.\Compare-FirewallDrift.ps1

Open the newest drift-*.csv; you should see AUDIT-TEST-RULE flagged. If a clean run (before step 3) said "No drift" and the dirty run flagged the test rule, the audit is working.

Undo the test and reset the baseline

The audit changes nothing, so there's nothing to roll back from it. Only the verification test above created a rule — remove it:

Remove-NetFirewallRule -DisplayName "AUDIT-TEST-RULE"

Remove-NetFirewallRule is immediate and not reversible, so match on the exact DisplayName you created and nothing broader.

After any approved firewall change (not the test), re-run Capture-FirewallBaseline.ps1 to fold that change into the reference — otherwise every future report will keep flagging it. And if you ever need to restore the whole firewall config wholesale, that's the netsh advfirewall import of the .wfw file you exported before you started.

For the exact parameters on any cmdlet here, see Microsoft Learn for the NetSecurity module (Get-NetFirewallRule, Get-NetFirewallPortFilter, New-NetFirewallRule, Remove-NetFirewallRule).

Written by
Ketan Aagja

Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.

More about the author →

Was this article helpful?

Tap a star — no sign-in needed.

Be the first to rate this article.

Automate Windows Firewall Rule Deployment with PowerShell and netsh

This guide builds and deploys Windows Defender Firewall rules from a table (a CSV), using the NetSecurity PowerShell module, with netsh advfirewall shown as the older equivalent. The goal is a repeatable, idempotent way to push the same rule set to one host or many, instead of clicking through wf.msc on each box.

9 min read

Audit Local Administrators Across Many Windows Machines

This is a read-only audit. It pulls a list of computers from Active Directory, connects to each one over PowerShell Remoting (WinRM), reads the membership of the local Administrators group, and writes everything to a single CSV you can open in Excel. It creates nothing and changes nothing on the target machines, so there is no destructive step and nothing to roll back — the only thing produced is the report file on your own workstation.

9 min read

Find and Report AD Accounts That Have Never Logged In

This guide gives you a PowerShell script that reads Active Directory and produces a report (on screen and as a CSV) of user accounts that have never authenticated against any domain controller. It changes nothing — it does not disable, delete, or edit a single account. Its purpose is to hand you a clean list of candidates for review before you decide what to do with them.

9 min read