Windows Firewall Analyzer in Docker

Audit Windows Firewall rules for security gaps using PowerShell in Docker.

Introduction

30 Second Summary

Every server connected to a network has a firewall controlling what traffic gets in and out. When those firewall rules are misconfigured, attackers can slip through open doors that no one even knew existed.

In this project, you will build a PowerShell script that analyzes Windows Firewall rules inside a Docker container on your Mac. You will load realistic firewall data, run security checks against it, and generate an audit report that highlights vulnerabilities.

What You'll Build

A PowerShell-based Windows Firewall Rule Analyzer running in a Docker container that processes firewall rule data and generates a security audit report.

By the end of this project, you'll have:

  • A JSON data file containing 15+ realistic Windows Firewall rules covering RDP, HTTP, SQL Server, SMB, and custom applications.
  • A PowerShell script with functions to load, filter, query, and audit firewall rules for security issues.
  • A formatted security audit report that identifies critical vulnerabilities like open RDP ports, overly broad allow rules, and disabled security rules.
  • Secret Mission: A rule comparison tool that diffs two firewall configurations and reports what changed between them.

Are there any prerequisites?

You need Docker Desktop installed on your Mac before starting this project. No Windows machine is required. The project runs everything inside a Docker container.

Set Up the PowerShell Docker Environment

Before you can analyze firewall rules, you need a PowerShell environment to run your scripts in.

Docker lets you run PowerShell inside a container on your Mac, even though PowerShell is a Windows-native tool. In this step, you will pull the official image, create a project folder, and confirm everything works.

In this step, get ready to:
  • Pull the Docker image that includes PowerShell.
  • Create a project folder and run a container with a volume mount.
  • Verify the PowerShell version and test a script file.
Pull the PowerShell Docker image

Microsoft publishes a .NET SDK image that includes PowerShell. This is the officially recommended way to run PowerShell in Docker.

  • In your terminal, pull the Docker image by running this command:
docker pull mcr.microsoft.com/dotnet/sdk:9.0

What does this command do?

docker pull downloads a pre-built image from Microsoft's container registry to your machine. The mcr.microsoft.com/dotnet/sdk:9.0 image contains the .NET 9.0 SDK with PowerShell included.

The download is approximately 850 MB. You will see progress bars as each layer downloads.

  • Verify the image starts correctly by running a quick test:
docker run --rm mcr.microsoft.com/dotnet/sdk:9.0 pwsh -Command "Write-Host 'PowerShell is ready'"

You should see PowerShell is ready printed in your terminal.

Why this image instead of mcr.microsoft.com/powershell?

The PowerShell team no longer maintains the standalone PowerShell Docker images. Microsoft's official documentation now recommends the .NET SDK image for running PowerShell in containers.

The .NET SDK image also avoids compatibility issues on Apple Silicon Macs that the older PowerShell image can trigger.

Seeing "Cannot connect to the Docker daemon"?

Make sure Docker Desktop is running. Look for the Docker whale icon in your menu bar at the top of your screen.

If the pull is very slow, check your internet connection. The image is large and may take a few minutes on slower networks.

Help me troubleshoot my Docker pull command

Create the project folder and start the container

A volume mount connects a folder on your Mac to a folder inside the container. Any files you create inside the container will persist on your Mac even after the container stops.

  • Create the project folder by running these commands:
mkdir ~/firewall-analyzer
cd ~/firewall-analyzer
  • Confirm the folder exists by running:
ls ~/firewall-analyzer

The folder is empty for now, so you will see no output. That is expected.

  • Start the Docker container with a volume mount by running this command:
docker run -it -v ~/firewall-analyzer:/workspace -w /workspace mcr.microsoft.com/dotnet/sdk:9.0 pwsh

What do these flags do?

  • -it starts an interactive terminal session so you can type commands inside the container.
  • -v ~/firewall-analyzer:/workspace mounts your Mac's firewall-analyzer folder to /workspace inside the container.
  • -w /workspace sets the working directory inside the container to /workspace.
  • pwsh launches the PowerShell shell instead of the default bash.

Your terminal prompt should change to something like PS /workspace>. You are now inside a Linux container running PowerShell.

Don't see the PowerShell prompt?

If you see a permission error about the volume mount, open Docker Desktop, go to Settings then Resources then File sharing, and make sure your home directory is allowed.

Help me get my Docker container running with PowerShell

Verify the PowerShell version and test a script file

Now that you are inside the container, confirm which version of PowerShell is available. Then create a test script to prove the volume mount works both ways.

  • Check the PowerShell version by running this inside the container:
$PSVersionTable

You should see a table showing PSVersion as 7.5 or higher, and PSEdition as Core.

  • Create a test script file by running this command:
Set-Content -Path "/workspace/test.ps1" -Value 'Write-Host "Hello from PowerShell in Docker!"'
  • Run the test script:
./test.ps1

You should see Hello from PowerShell in Docker! printed in the terminal.

How do I know the volume mount is working?

The file you just created at /workspace/test.ps1 inside the container also exists at ~/firewall-analyzer/test.ps1 on your Mac. Open a second terminal window and check for yourself.

This means any scripts you write inside the container are automatically saved to your Mac. If the container stops, your work persists.

  • Clean up the test file now that you have confirmed everything works:
Remove-Item /workspace/test.ps1

Seeing "UnauthorizedAccess" when running the script?

PowerShell's execution policy may be set to restricted. Run Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass to allow scripts for this session.

Help me fix PowerShell execution policy errors in Docker

Your PowerShell environment is ready to go. Next up, you will create the firewall rule data that your analyzer will inspect.

Create the Sample Firewall Rule Data

Your PowerShell environment is running inside Docker and ready for work. Now you need data to analyze.

Real Windows servers use the Get-NetFirewallRule cmdlet to export firewall configuration. Since you are running PowerShell on macOS via Docker, you will create a JSON file that mirrors the exact structure of that cmdlet's output.

In this step, get ready to:
  • Create a JSON file containing 15 realistic Windows Firewall rules.
  • Identify intentionally problematic rules hidden inside the data.
  • Load and verify the rules inside the PowerShell container.
Create the firewall rules JSON file

Each rule in your JSON file includes the properties that Get-NetFirewallRule returns on a real Windows server: DisplayName, Name, Direction, Action, Protocol, LocalPort, RemotePort, LocalAddress, RemoteAddress, Profile, Enabled, Group, and Description.

  • Create the firewall rules data file by running this command in your PowerShell container:
@'
[
  {
    "DisplayName": "Remote Desktop (TCP-In)",
    "Name": "RemoteDesktop-UserMode-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "3389",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Remote Desktop",
    "Description": "Allow inbound Remote Desktop connections"
  },
  {
    "DisplayName": "HTTP Web Server (TCP-In)",
    "Name": "WebServer-HTTP-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "80",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Web Server",
    "Description": "Allow inbound HTTP traffic"
  },
  {
    "DisplayName": "HTTPS Web Server (TCP-In)",
    "Name": "WebServer-HTTPS-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "443",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Web Server",
    "Description": "Allow inbound HTTPS traffic"
  },
  {
    "DisplayName": "SQL Server (TCP-In)",
    "Name": "SQLServer-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "1433",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private",
    "Enabled": true,
    "Group": "SQL Server",
    "Description": "Allow inbound SQL Server connections"
  },
  {
    "DisplayName": "SMB File Sharing (TCP-In)",
    "Name": "FPS-SMB-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "445",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "File and Printer Sharing",
    "Description": "Allow inbound SMB file sharing"
  },
  {
    "DisplayName": "DNS Client (UDP-Out)",
    "Name": "DNSClient-Out-UDP",
    "Direction": "Outbound",
    "Action": "Allow",
    "Protocol": "UDP",
    "LocalPort": "Any",
    "RemotePort": "53",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "DNS Client",
    "Description": "Allow outbound DNS queries"
  },
  {
    "DisplayName": "ICMP Echo Request (ICMPv4-In)",
    "Name": "CoreNet-ICMP4-DUFRAG-In",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "ICMPv4",
    "LocalPort": "Any",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "LocalSubnet",
    "Profile": "Domain, Private",
    "Enabled": true,
    "Group": "Core Networking",
    "Description": "Allow inbound ICMP echo requests from local subnet"
  },
  {
    "DisplayName": "Block Telnet (TCP-In)",
    "Name": "Block-Telnet-In-TCP",
    "Direction": "Inbound",
    "Action": "Block",
    "Protocol": "TCP",
    "LocalPort": "23",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Security Rules",
    "Description": "Block inbound Telnet connections"
  },
  {
    "DisplayName": "Block FTP (TCP-In)",
    "Name": "Block-FTP-In-TCP",
    "Direction": "Inbound",
    "Action": "Block",
    "Protocol": "TCP",
    "LocalPort": "21",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": false,
    "Group": "Security Rules",
    "Description": "Block inbound FTP connections"
  },
  {
    "DisplayName": "Custom App Server (TCP-In)",
    "Name": "CustomApp-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "8080",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "192.168.1.0/24",
    "Profile": "Domain, Private",
    "Enabled": true,
    "Group": "Custom Applications",
    "Description": "Allow custom application on port 8080 from internal network"
  },
  {
    "DisplayName": "Allow All Inbound (DANGER)",
    "Name": "DangerousRule-AllowAll-In",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "Any",
    "LocalPort": "Any",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Public",
    "Enabled": true,
    "Group": "Miscellaneous",
    "Description": "Allows all inbound traffic - SECURITY RISK"
  },
  {
    "DisplayName": "Windows Update (TCP-Out)",
    "Name": "WindowsUpdate-Out-TCP",
    "Direction": "Outbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "Any",
    "RemotePort": "443",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Windows Update",
    "Description": "Allow outbound HTTPS for Windows Update"
  },
  {
    "DisplayName": "SSH Server (TCP-In)",
    "Name": "SSH-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "22",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "10.0.0.0/8",
    "Profile": "Domain, Private",
    "Enabled": true,
    "Group": "Remote Access",
    "Description": "Allow SSH from internal network"
  },
  {
    "DisplayName": "Block NetBIOS (UDP-In)",
    "Name": "Block-NetBIOS-In-UDP",
    "Direction": "Inbound",
    "Action": "Block",
    "Protocol": "UDP",
    "LocalPort": "137-139",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Public",
    "Enabled": false,
    "Group": "Security Rules",
    "Description": "Block inbound NetBIOS on public networks"
  },
  {
    "DisplayName": "NTP Time Sync (UDP-Out)",
    "Name": "NTP-Out-UDP",
    "Direction": "Outbound",
    "Action": "Allow",
    "Protocol": "UDP",
    "LocalPort": "Any",
    "RemotePort": "123",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Core Networking",
    "Description": "Allow outbound NTP time synchronization"
  }
]
'@ | Set-Content -Path /workspace/firewall-rules.json -Encoding UTF8

What does this file contain?

This JSON file holds 15 firewall rules covering the scenarios you would find on a typical Windows server. The rules span common services like RDP, HTTP/HTTPS, SQL Server, SMB, DNS, SSH, and more.

Each rule uses the exact property names that Get-NetFirewallRule returns on a real Windows machine. The Direction field is either Inbound or Outbound, and Action is either Allow or Block.

  • Confirm the file was created by running:
Test-Path /workspace/firewall-rules.json

You should see True printed in your terminal.

Seeing False or an error?

Make sure you pasted the entire command including both the opening @' and the closing '@ markers. The closing '@ must appear at the very start of a new line with no spaces before it.

If your terminal shows >> prompts waiting for more input, PowerShell did not detect the closing marker. Press Ctrl+C to cancel and try pasting again.

help me fix the file creation command

Spot the intentionally problematic rules

Not all 15 rules are secure. Three rules contain deliberate security issues that your audit engine will catch in a later step.

What are the hidden security problems?

  • Remote Desktop (TCP-In) allows RDP on port 3389 from Any remote address across all profiles. A properly secured rule would restrict RemoteAddress to a specific subnet.
  • Allow All Inbound (DANGER) sets Protocol to Any and LocalPort to Any. This effectively disables the firewall for inbound traffic on the Public profile.
  • Block FTP (TCP-In) and Block NetBIOS (UDP-In) are security block rules with Enabled set to false. Disabled block rules mean the protection they provide is not active.

Why plant vulnerabilities on purpose?

Real server configurations accumulate risky rules over time as teams make quick fixes and forget to tighten them. By including known issues in your sample data, you can verify that your audit engine actually catches them.

Load the JSON and verify the data structure

With the file in place, load it into PowerShell and confirm the data is structured correctly.

  • Load the rules and display a summary count by direction and action by running:
# Load the JSON file into a PowerShell array
$rules = Get-Content -Path /workspace/firewall-rules.json -Raw | ConvertFrom-Json

# Show total rule count
Write-Host "Total rules loaded: $($rules.Count)" -ForegroundColor Green

# Group by Direction and Action to show a summary breakdown
Write-Host ""
Write-Host "Rules by Direction and Action:" -ForegroundColor Cyan
$rules | Group-Object -Property Direction, Action | ForEach-Object {
    Write-Host "  $($_.Name): $($_.Count)" -ForegroundColor Yellow
}

What does this output tell you?

Get-Content -Raw reads the entire file as a single string. ConvertFrom-Json parses that string into PowerShell objects you can filter and group.

The Group-Object command clusters rules by their Direction and Action combination. This gives you a quick snapshot of how the firewall is configured before diving into individual rules.

You should see output showing 15 total rules with the following breakdown:

  • Inbound, Allow: 9
  • Inbound, Block: 3
  • Outbound, Allow: 3

Seeing a parse error or 0 rules?

If you see a ConvertFrom-Json error, the JSON file may be malformed. Run Get-Content /workspace/firewall-rules.json | Select-Object -First 5 to check that the file starts with [ and contains valid JSON.

If $rules.Count shows 0 or 1, the file might have been saved with incorrect encoding or the heredoc did not expand properly. Delete the file with Remove-Item /workspace/firewall-rules.json and re-run the creation command.

help me debug my JSON file loading

✔️ Awesome, I've got everything!

Great. Make sure your terminal is still showing the PowerShell prompt inside the container before continuing.

ⓧ I'd like to double check the full code

Here is the complete firewall-rules.json file. Compare your version character by character:

[
  {
    "DisplayName": "Remote Desktop (TCP-In)",
    "Name": "RemoteDesktop-UserMode-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "3389",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Remote Desktop",
    "Description": "Allow inbound Remote Desktop connections"
  },
  {
    "DisplayName": "HTTP Web Server (TCP-In)",
    "Name": "WebServer-HTTP-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "80",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Web Server",
    "Description": "Allow inbound HTTP traffic"
  },
  {
    "DisplayName": "HTTPS Web Server (TCP-In)",
    "Name": "WebServer-HTTPS-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "443",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Web Server",
    "Description": "Allow inbound HTTPS traffic"
  },
  {
    "DisplayName": "SQL Server (TCP-In)",
    "Name": "SQLServer-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "1433",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private",
    "Enabled": true,
    "Group": "SQL Server",
    "Description": "Allow inbound SQL Server connections"
  },
  {
    "DisplayName": "SMB File Sharing (TCP-In)",
    "Name": "FPS-SMB-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "445",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "File and Printer Sharing",
    "Description": "Allow inbound SMB file sharing"
  },
  {
    "DisplayName": "DNS Client (UDP-Out)",
    "Name": "DNSClient-Out-UDP",
    "Direction": "Outbound",
    "Action": "Allow",
    "Protocol": "UDP",
    "LocalPort": "Any",
    "RemotePort": "53",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "DNS Client",
    "Description": "Allow outbound DNS queries"
  },
  {
    "DisplayName": "ICMP Echo Request (ICMPv4-In)",
    "Name": "CoreNet-ICMP4-DUFRAG-In",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "ICMPv4",
    "LocalPort": "Any",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "LocalSubnet",
    "Profile": "Domain, Private",
    "Enabled": true,
    "Group": "Core Networking",
    "Description": "Allow inbound ICMP echo requests from local subnet"
  },
  {
    "DisplayName": "Block Telnet (TCP-In)",
    "Name": "Block-Telnet-In-TCP",
    "Direction": "Inbound",
    "Action": "Block",
    "Protocol": "TCP",
    "LocalPort": "23",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Security Rules",
    "Description": "Block inbound Telnet connections"
  },
  {
    "DisplayName": "Block FTP (TCP-In)",
    "Name": "Block-FTP-In-TCP",
    "Direction": "Inbound",
    "Action": "Block",
    "Protocol": "TCP",
    "LocalPort": "21",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": false,
    "Group": "Security Rules",
    "Description": "Block inbound FTP connections"
  },
  {
    "DisplayName": "Custom App Server (TCP-In)",
    "Name": "CustomApp-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "8080",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "192.168.1.0/24",
    "Profile": "Domain, Private",
    "Enabled": true,
    "Group": "Custom Applications",
    "Description": "Allow custom application on port 8080 from internal network"
  },
  {
    "DisplayName": "Allow All Inbound (DANGER)",
    "Name": "DangerousRule-AllowAll-In",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "Any",
    "LocalPort": "Any",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Public",
    "Enabled": true,
    "Group": "Miscellaneous",
    "Description": "Allows all inbound traffic - SECURITY RISK"
  },
  {
    "DisplayName": "Windows Update (TCP-Out)",
    "Name": "WindowsUpdate-Out-TCP",
    "Direction": "Outbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "Any",
    "RemotePort": "443",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Windows Update",
    "Description": "Allow outbound HTTPS for Windows Update"
  },
  {
    "DisplayName": "SSH Server (TCP-In)",
    "Name": "SSH-In-TCP",
    "Direction": "Inbound",
    "Action": "Allow",
    "Protocol": "TCP",
    "LocalPort": "22",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "10.0.0.0/8",
    "Profile": "Domain, Private",
    "Enabled": true,
    "Group": "Remote Access",
    "Description": "Allow SSH from internal network"
  },
  {
    "DisplayName": "Block NetBIOS (UDP-In)",
    "Name": "Block-NetBIOS-In-UDP",
    "Direction": "Inbound",
    "Action": "Block",
    "Protocol": "UDP",
    "LocalPort": "137-139",
    "RemotePort": "Any",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Public",
    "Enabled": false,
    "Group": "Security Rules",
    "Description": "Block inbound NetBIOS on public networks"
  },
  {
    "DisplayName": "NTP Time Sync (UDP-Out)",
    "Name": "NTP-Out-UDP",
    "Direction": "Outbound",
    "Action": "Allow",
    "Protocol": "UDP",
    "LocalPort": "Any",
    "RemotePort": "123",
    "LocalAddress": "Any",
    "RemoteAddress": "Any",
    "Profile": "Domain, Private, Public",
    "Enabled": true,
    "Group": "Core Networking",
    "Description": "Allow outbound NTP time synchronization"
  }
]

Your sample data is loaded and verified. Next up, you will write PowerShell functions that can filter and query these rules by any property combination.

Build the Query and Filter Functions

You now have a container running PowerShell with a realistic set of firewall rules sitting in firewall-rules.json.

Raw data on its own is not useful. To find security issues, you need functions that can load, filter, and summarize those rules on demand. In this step, you will build three reusable PowerShell functions that form the querying backbone of your analyzer.

In this step, get ready to:
  • Write a function that loads firewall rules from a JSON file.
  • Write a function that filters rules by direction, action, protocol, profile, and enabled status.
  • Write a function that summarizes rule counts by category.
Write the data loading and filter functions

Before you can analyze rules, your script needs a reliable way to load the JSON data into memory. It also needs a flexible filter function so you can slice the data by any property combination.

  • Create a new file called FirewallAnalyzer.ps1 inside your firewall-analyzer folder on your Mac (the folder mounted to /workspace inside the container).
  • Add the file header and the Get-FirewallRules function by pasting this code:
# FirewallAnalyzer.ps1
# Windows Firewall Rule Analyzer

function Get-FirewallRules {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    if (-not (Test-Path $Path)) {
        Write-Error "File not found: $Path"
        return
    }
    $rules = Get-Content -Path $Path -Raw | ConvertFrom-Json
    Write-Host "Loaded $($rules.Count) firewall rules from $Path" -ForegroundColor Green
    return $rules
}

What does this code do?

The Get-FirewallRules function takes a file path as a mandatory parameter and returns an array of rule objects.

  • Test-Path checks that the JSON file actually exists before trying to read it.
  • Get-Content -Raw reads the entire file as a single string, then ConvertFrom-Json parses it into PowerShell objects.
  • The Write-Host line gives you a quick confirmation of how many rules were loaded.

You will not see a change yet. The next chunk adds the filter function that works alongside this loader.

  • Below the Get-FirewallRules function in the same file, add the Find-FirewallRule function:
function Find-FirewallRule {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Rules,
        [string]$Direction,
        [string]$Action,
        [string]$Protocol,
        [string]$Profile,
        [Nullable[bool]]$Enabled
    )
    $filtered = $Rules
    if ($Direction) { $filtered = $filtered | Where-Object { $_.Direction -eq $Direction } }
    if ($Action) { $filtered = $filtered | Where-Object { $_.Action -eq $Action } }
    if ($Protocol) { $filtered = $filtered | Where-Object { $_.Protocol -eq $Protocol } }
    if ($Profile) { $filtered = $filtered | Where-Object { $_.Profile -like "*$Profile*" } }
    if ($null -ne $Enabled) { $filtered = $filtered | Where-Object { $_.Enabled -eq $Enabled } }
    return $filtered
}

What does this code do?

Find-FirewallRule accepts an array of rules plus optional filter parameters. Each if block only applies its filter when the caller provides that parameter, so you can combine any subset of criteria in a single call.

  • Direction, Action, and Protocol use -eq for exact matching.
  • Profile uses -like with wildcards because a rule can belong to multiple profiles (e.g., "Domain, Private, Public").
  • Enabled uses $null -ne $Enabled to distinguish between "not provided" and an explicit $false value.
  • Save FirewallAnalyzer.ps1.

File not showing up in the container?

Make sure you saved the file inside the firewall-analyzer folder on your Mac. The volume mount maps that folder to /workspace inside the container.

Confirm the file exists by running ls /workspace/FirewallAnalyzer.ps1 inside the container. If nothing appears, double-check the file name matches exactly (case-sensitive).

Help me figure out why my file isn't visible in the container.

Write the rule summary function

A summary view tells you at a glance how many rules are inbound vs. outbound and how many allow vs. block. This is the first thing a security analyst checks when reviewing a firewall configuration.

  • Below the Find-FirewallRule function in FirewallAnalyzer.ps1, add the Get-RuleSummary function:
function Get-RuleSummary {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Rules
    )
    Write-Host "`n=== FIREWALL RULE SUMMARY ===" -ForegroundColor Cyan
    Write-Host "Total Rules: $($Rules.Count)"
    Write-Host ""
    $groups = $Rules | Group-Object -Property Direction, Action
    foreach ($group in $groups) {
        Write-Host "  $($group.Name): $($group.Count)" -ForegroundColor Yellow
    }
    Write-Host ""
    $enabledCount = ($Rules | Where-Object { $_.Enabled -eq $true }).Count
    $disabledCount = ($Rules | Where-Object { $_.Enabled -eq $false }).Count
    Write-Host "  Enabled: $enabledCount | Disabled: $disabledCount"
    Write-Host ""
}

What does this code do?

Get-RuleSummary gives you a bird's-eye view of your firewall posture.

  • Group-Object -Property Direction, Action groups rules by their combined direction and action (e.g., "Inbound, Allow" or "Inbound, Block"), then counts each group.
  • The enabled/disabled count at the bottom flags how many rules exist but are not currently enforced.
  • Save FirewallAnalyzer.ps1.

Seeing a syntax error when you try to source the file?

Check that the backtick in `n (PowerShell's newline escape) was not accidentally converted to a regular single quote. The line should read Write-Host "`n=== FIREWALL RULE SUMMARY ===".

Also verify that every opening { brace has a matching closing } brace. Missing braces cause unexpected token errors.

Help me debug a syntax error in my PowerShell script.

Test the functions interactively

Now that your script file is complete, you can load it into your running PowerShell session and confirm each function works against the sample data.

  • In your PowerShell container session, dot-source the script to load all three functions into memory by running:
. /workspace/FirewallAnalyzer.ps1

What is dot-sourcing?

The dot (.) followed by a space and a file path tells PowerShell to execute the script in the current scope. This makes all functions defined in the file available for you to call directly.

  • Load the firewall rules into a variable by running:
$rules = Get-FirewallRules -Path "/workspace/firewall-rules.json"

You should see a green message: Loaded 15 firewall rules from /workspace/firewall-rules.json.

  • Test the filter function by finding all inbound block rules:
Find-FirewallRule -Rules $rules -Direction "Inbound" -Action "Block"

You should see three rules returned: Block Telnet, Block FTP, and Block NetBIOS.

  • Test the summary function by running:
Get-RuleSummary -Rules $rules

You should see a formatted table showing counts for each Direction/Action combination (e.g., Inbound, Allow: 8) and the enabled/disabled split.

Seeing 'command not found' errors?

Make sure you ran the dot-source command (. /workspace/FirewallAnalyzer.ps1) first. Without it, the functions are not loaded into your session.

If dot-sourcing itself throws an error, check for typos in your script file. A common issue is a missing closing brace at the end of a function.

Help me troubleshoot why my PowerShell functions are not loading.

✔️ Awesome, I've got everything!

Great. Double check you have saved FirewallAnalyzer.ps1 and all three functions respond correctly when tested.

ⓧ I'd like to double check the full code

Here is the complete FirewallAnalyzer.ps1 file as it should look at the end of this step:

# FirewallAnalyzer.ps1
# Windows Firewall Rule Analyzer

function Get-FirewallRules {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    if (-not (Test-Path $Path)) {
        Write-Error "File not found: $Path"
        return
    }
    $rules = Get-Content -Path $Path -Raw | ConvertFrom-Json
    Write-Host "Loaded $($rules.Count) firewall rules from $Path" -ForegroundColor Green
    return $rules
}

function Find-FirewallRule {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Rules,
        [string]$Direction,
        [string]$Action,
        [string]$Protocol,
        [string]$Profile,
        [Nullable[bool]]$Enabled
    )
    $filtered = $Rules
    if ($Direction) { $filtered = $filtered | Where-Object { $_.Direction -eq $Direction } }
    if ($Action) { $filtered = $filtered | Where-Object { $_.Action -eq $Action } }
    if ($Protocol) { $filtered = $filtered | Where-Object { $_.Protocol -eq $Protocol } }
    if ($Profile) { $filtered = $filtered | Where-Object { $_.Profile -like "*$Profile*" } }
    if ($null -ne $Enabled) { $filtered = $filtered | Where-Object { $_.Enabled -eq $Enabled } }
    return $filtered
}

function Get-RuleSummary {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Rules
    )
    Write-Host "`n=== FIREWALL RULE SUMMARY ===" -ForegroundColor Cyan
    Write-Host "Total Rules: $($Rules.Count)"
    Write-Host ""
    $groups = $Rules | Group-Object -Property Direction, Action
    foreach ($group in $groups) {
        Write-Host "  $($group.Name): $($group.Count)" -ForegroundColor Yellow
    }
    Write-Host ""
    $enabledCount = ($Rules | Where-Object { $_.Enabled -eq $true }).Count
    $disabledCount = ($Rules | Where-Object { $_.Enabled -eq $false }).Count
    Write-Host "  Enabled: $enabledCount | Disabled: $disabledCount"
    Write-Host ""
}

Your query functions are ready. Next up, you will build the security audit engine that uses these functions to automatically detect dangerous rules in your firewall configuration.

Build the Security Audit Engine

You can now load, filter, and summarize firewall rules. The real value of this tool, though, comes from automated security analysis.

Reviewing 15 rules by hand is manageable. A production Windows server might have hundreds. In this step, you'll build an audit engine that checks every rule against a set of security policies and flags violations automatically.

In this step, get ready to:
  • Write an Invoke-FirewallAudit function that checks rules against four security policies.
  • Assign severity levels (Critical, Warning) to each finding and return structured result objects.
  • Test the audit against your sample data and confirm it catches the intentional security issues.
Write the Invoke-FirewallAudit function

The audit function iterates through every rule and checks it against a list of known-bad patterns. Each pattern that matches produces a finding with a severity level and a recommendation.

  • In your FirewallAnalyzer.ps1 file, find the closing brace of the Get-RuleSummary function.
  • Add the following Invoke-FirewallAudit function below it:
function Invoke-FirewallAudit {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Rules
    )
    $findings = @()
    $sensitivePorts = @("3389", "445", "1433", "23", "21")
    foreach ($rule in $Rules) {
        if ($rule.Action -eq "Allow" -and $rule.Direction -eq "Inbound" -and $rule.RemoteAddress -eq "Any" -and $rule.LocalPort -in $sensitivePorts -and $rule.Enabled -eq $true) {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Critical"; Finding = "Sensitive port $($rule.LocalPort) is open to all remote addresses"; Recommendation = "Restrict RemoteAddress to specific IP ranges or subnets" }
        }
        if ($rule.Action -eq "Allow" -and $rule.Protocol -eq "Any" -and $rule.LocalPort -eq "Any" -and $rule.Enabled -eq $true) {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Critical"; Finding = "Rule allows ALL protocols and ALL ports"; Recommendation = "Remove or restrict this rule to specific protocols and ports" }
        }
        if ($rule.Action -eq "Block" -and $rule.Group -eq "Security Rules" -and $rule.Enabled -eq $false) {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Warning"; Finding = "Security block rule is DISABLED"; Recommendation = "Enable this rule to enforce the intended security policy" }
        }
        if ($rule.Action -eq "Allow" -and $rule.Direction -eq "Inbound" -and $rule.Profile -like "*Public*" -and $rule.RemoteAddress -eq "Any" -and $rule.Enabled -eq $true -and $rule.Protocol -ne "ICMPv4") {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Warning"; Finding = "Inbound allow rule active on Public profile with no address restriction"; Recommendation = "Review if this rule should apply to Public networks; restrict if possible" }
        }
    }
    return $findings
}

What does this function do?

The function loops through every rule and applies four independent security checks. Each check that matches produces a finding object.

  • Check 1: Flags any inbound Allow rule that exposes a sensitive port (RDP 3389, SMB 445, SQL 1433, Telnet 23, FTP 21) to all remote addresses. This is Critical because these services should never be wide open to the internet.
  • Check 2: Flags any enabled rule that allows ALL protocols on ALL ports. This is Critical because it effectively disables the firewall for that traffic direction.
  • Check 3: Flags security block rules that have been disabled. This is a Warning because someone intentionally turned off a protective rule.
  • Check 4: Flags inbound Allow rules active on the Public profile with no address restriction. This is a Warning because Public networks (like coffee shop Wi-Fi) are untrusted.
  • Save FirewallAnalyzer.ps1.

Seeing a syntax error when you save?

  • Check that every opening brace { has a matching closing brace }. The function has a foreach loop inside the function body, so you need braces for both.
  • Make sure the [PSCustomObject]@{ lines use semicolons ; to separate properties on a single line.
  • Verify the -like "*Public*" operator in Check 4 uses double quotes around the wildcard pattern.

Help me debug a syntax error in my Invoke-FirewallAudit PowerShell function

Understand the severity levels and finding structure

Each finding your audit produces is a structured object with four fields. This consistent structure makes it easy to sort, filter, and format findings later in the report.

How are severity levels assigned?

The audit assigns two severity levels based on how dangerous the misconfiguration is.

  • Critical means the rule creates an immediate, exploitable vulnerability. Sensitive ports open to the entire internet or a rule that disables the firewall entirely fall into this category.
  • Warning means the rule weakens your security posture but may not be immediately exploitable. Disabled block rules and overly broad Public-profile rules fall here.

Every finding also includes a Recommendation field that tells the administrator exactly what to do. This makes the report actionable, not just informational.

Why not just block everything?

A firewall that blocks all traffic is perfectly secure but completely useless. The art of firewall management is allowing the minimum traffic needed for legitimate services while blocking everything else.

The audit doesn't flag Allow rules as bad. It flags Allow rules that are too broad. RDP is fine when restricted to your internal network. RDP open to the entire internet is a Critical finding.

Test the audit function against the sample data

Your sample data includes intentionally problematic rules. Time to confirm the audit catches them all.

  • In your PowerShell container, reload the script and run the audit by entering these commands:
. /workspace/FirewallAnalyzer.ps1
$rules = Get-FirewallRules -Path "/workspace/firewall-rules.json"
$findings = Invoke-FirewallAudit -Rules $rules
$findings | Format-Table -AutoSize

What does this command sequence do?

  • . /workspace/FirewallAnalyzer.ps1 dot-sources the script, which loads all its functions into your current session.
  • Get-FirewallRules loads the JSON data into memory.
  • Invoke-FirewallAudit runs the four security checks against every rule and collects findings.
  • Format-Table -AutoSize displays the results in a clean table layout.

You should see a table with multiple findings. The Critical findings include "Remote Desktop (TCP-In)" for having port 3389 open to all addresses, "SQL Server (TCP-In)" for port 1433, "SMB File Sharing (TCP-In)" for port 445, and "Allow All Inbound (DANGER)" for allowing all protocols and ports.

  • Verify the Critical findings by running:
$findings | Where-Object { $_.Severity -eq "Critical" } | Format-Table -AutoSize

You should see 4 Critical findings. Three are for sensitive ports open to all remote addresses, and one is for the rule that allows all protocols and all ports.

  • Verify the Warning findings by running:
$findings | Where-Object { $_.Severity -eq "Warning" } | Format-Table -AutoSize

You should see 7 Warning findings. Two are for disabled Security Rules block rules (Block FTP and Block NetBIOS). Five are for inbound Allow rules active on the Public profile with no address restriction.

Not seeing any findings?

  • Check that you dot-sourced the script with a period and a space before the path: . /workspace/FirewallAnalyzer.ps1. Running without the dot executes the script in a child scope and your session won't have the functions.
  • If you see an error about the function not being recognized, confirm you saved the file after adding the new function. Re-source it after saving.
  • If the table is empty, verify that $rules.Count returns 15. If it returns 0, your JSON file path may be incorrect.

Help me troubleshoot why my Invoke-FirewallAudit function returns no findings

✔️ Awesome, I've got everything!

Great. Double check you've saved FirewallAnalyzer.ps1 with the new Invoke-FirewallAudit function.

ⓧ I'd like to double check the full code

Here is the full FirewallAnalyzer.ps1 file as it should look now:

# FirewallAnalyzer.ps1
# Windows Firewall Rule Analyzer

function Get-FirewallRules {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    if (-not (Test-Path $Path)) {
        Write-Error "File not found: $Path"
        return
    }
    $rules = Get-Content -Path $Path -Raw | ConvertFrom-Json
    Write-Host "Loaded $($rules.Count) firewall rules from $Path" -ForegroundColor Green
    return $rules
}

function Find-FirewallRule {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Rules,
        [string]$Direction,
        [string]$Action,
        [string]$Protocol,
        [string]$Profile,
        [Nullable[bool]]$Enabled
    )
    $filtered = $Rules
    if ($Direction) { $filtered = $filtered | Where-Object { $_.Direction -eq $Direction } }
    if ($Action) { $filtered = $filtered | Where-Object { $_.Action -eq $Action } }
    if ($Protocol) { $filtered = $filtered | Where-Object { $_.Protocol -eq $Protocol } }
    if ($Profile) { $filtered = $filtered | Where-Object { $_.Profile -like "*$Profile*" } }
    if ($null -ne $Enabled) { $filtered = $filtered | Where-Object { $_.Enabled -eq $Enabled } }
    return $filtered
}

function Get-RuleSummary {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Rules
    )
    Write-Host "`n=== FIREWALL RULE SUMMARY ===" -ForegroundColor Cyan
    Write-Host "Total Rules: $($Rules.Count)"
    Write-Host ""
    $groups = $Rules | Group-Object -Property Direction, Action
    foreach ($group in $groups) {
        Write-Host "  $($group.Name): $($group.Count)" -ForegroundColor Yellow
    }
    Write-Host ""
    $enabledCount = ($Rules | Where-Object { $_.Enabled -eq $true }).Count
    $disabledCount = ($Rules | Where-Object { $_.Enabled -eq $false }).Count
    Write-Host "  Enabled: $enabledCount | Disabled: $disabledCount"
    Write-Host ""
}

function Invoke-FirewallAudit {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Rules
    )
    $findings = @()
    $sensitivePorts = @("3389", "445", "1433", "23", "21")
    foreach ($rule in $Rules) {
        if ($rule.Action -eq "Allow" -and $rule.Direction -eq "Inbound" -and $rule.RemoteAddress -eq "Any" -and $rule.LocalPort -in $sensitivePorts -and $rule.Enabled -eq $true) {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Critical"; Finding = "Sensitive port $($rule.LocalPort) is open to all remote addresses"; Recommendation = "Restrict RemoteAddress to specific IP ranges or subnets" }
        }
        if ($rule.Action -eq "Allow" -and $rule.Protocol -eq "Any" -and $rule.LocalPort -eq "Any" -and $rule.Enabled -eq $true) {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Critical"; Finding = "Rule allows ALL protocols and ALL ports"; Recommendation = "Remove or restrict this rule to specific protocols and ports" }
        }
        if ($rule.Action -eq "Block" -and $rule.Group -eq "Security Rules" -and $rule.Enabled -eq $false) {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Warning"; Finding = "Security block rule is DISABLED"; Recommendation = "Enable this rule to enforce the intended security policy" }
        }
        if ($rule.Action -eq "Allow" -and $rule.Direction -eq "Inbound" -and $rule.Profile -like "*Public*" -and $rule.RemoteAddress -eq "Any" -and $rule.Enabled -eq $true -and $rule.Protocol -ne "ICMPv4") {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Warning"; Finding = "Inbound allow rule active on Public profile with no address restriction"; Recommendation = "Review if this rule should apply to Public networks; restrict if possible" }
        }
    }
    return $findings
}

Your audit engine is working. It catches the intentional security issues in your sample data and produces structured findings you can act on. Next up, you'll format these findings into a proper audit report and export it to a file.

Generate the Formatted Audit Report

Your audit engine identifies security issues, but raw objects in a terminal aren't easy to share with a team or attach to a compliance ticket.

In this step, you will build a report generator that turns audit findings into a formatted, human-readable document. You will also wire every function together into a single pipeline that runs end-to-end and exports the final report to a file on your Mac.

In this step, get ready to:
  • Write a report function that groups findings by severity and calculates a risk score.
  • Add a main execution block that runs the full pipeline with one command.
  • Export the report to a persistent file and verify it appears on your Mac.
Write the report formatting function

The New-AuditReport function takes the structured findings from Invoke-FirewallAudit and produces a formatted text report. It groups findings by severity, calculates a risk score, and optionally exports the report to a file.

  • In your FirewallAnalyzer.ps1 file, find the second comment line at the top of the file:
# Windows Firewall Rule Analyzer
  • Add a third comment line directly below it so the header now looks like this:
# FirewallAnalyzer.ps1
# Windows Firewall Rule Analyzer
# Analyzes firewall rules exported in Get-NetFirewallRule JSON format
  • Scroll to the bottom of the file, below the closing } of Invoke-FirewallAudit.
  • Add the New-AuditReport function by copying the following code below it:
function New-AuditReport {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Findings,
        [string]$OutputPath
    )

    # Count findings at each severity level
    $criticalCount = ($Findings | Where-Object { $_.Severity -eq "Critical" }).Count
    $warningCount = ($Findings | Where-Object { $_.Severity -eq "Warning" }).Count
    $infoCount = ($Findings | Where-Object { $_.Severity -eq "Info" }).Count
    $totalFindings = $Findings.Count

    # Calculate risk score: Critical=10pts, Warning=5pts, Info=1pt
    $riskScore = ($criticalCount * 10) + ($warningCount * 5) + ($infoCount * 1)

    # Build report header using a here-string
    $report = @"
=====================================
  WINDOWS FIREWALL SECURITY AUDIT
  Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
=====================================

SUMMARY
-------
Total Findings: $totalFindings
  Critical: $criticalCount
  Warning:  $warningCount
  Info:     $infoCount

Risk Score: $riskScore / 100
Rating: $(if ($riskScore -ge 50) { "HIGH RISK" } elseif ($riskScore -ge 25) { "MEDIUM RISK" } else { "LOW RISK" })

"@

    # Append findings grouped by severity
    $severityOrder = @("Critical", "Warning", "Info")
    foreach ($severity in $severityOrder) {
        $severityFindings = $Findings | Where-Object { $_.Severity -eq $severity }
        if ($severityFindings.Count -gt 0) {
            $report += "--- $($severity.ToUpper()) FINDINGS ---`n`n"
            foreach ($finding in $severityFindings) {
                $report += "  Rule: $($finding.RuleName)`n"
                $report += "  Issue: $($finding.Finding)`n"
                $report += "  Action: $($finding.Recommendation)`n"
                $report += "`n"
            }
        }
    }

    $report += "=====================================$(`n)"
    $report += "  END OF REPORT`n"
    $report += "=====================================$(`n)"

    # Display report to console
    Write-Host $report

    # Export to file if an output path was provided
    if ($OutputPath) {
        $report | Out-File -FilePath $OutputPath -Encoding UTF8
        Write-Host "Report saved to: $OutputPath" -ForegroundColor Green
    }

    return $report
}

What does this function do?

  • $criticalCount, $warningCount, and $infoCount tally how many findings exist at each severity level.
  • $riskScore weights each finding (Critical = 10, Warning = 5, Info = 1) and sums them into a single number out of 100.
  • The @"..."@ here-string builds the report header with embedded variables that PowerShell expands at runtime.
  • The foreach loop groups findings by severity so Critical issues always appear first in the report.
  • The Out-File call at the end exports the report to disk only when an $OutputPath is provided. This keeps the function flexible for console-only use or file export.
  • Save FirewallAnalyzer.ps1.

You won't see a change yet. The next chunk adds the main execution block that calls this function.

Add the main execution block

Right now your script defines functions but never calls them. A main execution block at the bottom of the file wires everything together so running the script executes the full pipeline in one shot.

  • In FirewallAnalyzer.ps1, scroll to the very bottom of the file (below the closing } of New-AuditReport).
  • Add the main execution block by copying the following code below it:
# --- Main Execution ---

Write-Host "Windows Firewall Rule Analyzer" -ForegroundColor Cyan
Write-Host "==============================" -ForegroundColor Cyan
Write-Host ""

# Load rules from the JSON file
$rules = Get-FirewallRules -Path "/workspace/firewall-rules.json"

# Display the summary table
Get-RuleSummary -Rules $rules

# Run the security audit and generate the report
Write-Host "Running security audit..." -ForegroundColor Yellow
$findings = Invoke-FirewallAudit -Rules $rules
New-AuditReport -Findings $findings -OutputPath "/workspace/audit-report.txt"

What does this block do?

This is the orchestration layer. It calls every function you have built in the order they need to run.

  • Get-FirewallRules loads the JSON into memory.
  • Get-RuleSummary prints the quick count table so you know how many rules were loaded.
  • Invoke-FirewallAudit checks every rule against security policies.
  • New-AuditReport formats the findings and saves them to /workspace/audit-report.txt.
  • Save FirewallAnalyzer.ps1.
Run the full pipeline and verify the report

Time to see the entire analyzer work end-to-end. You will run the script inside the container and then confirm the report file appeared on your Mac.

  • Run the full analyzer pipeline inside the container by running this command:
pwsh /workspace/FirewallAnalyzer.ps1

You should see the rule summary table, followed by the full security audit report printed to the console. The last line confirms the report was saved to /workspace/audit-report.txt.

What should I see?

The report starts with a header showing the total findings count, critical and warning counts, a risk score, and a rating (likely HIGH RISK given the intentionally insecure rules).

Below the summary, you should see a CRITICAL FINDINGS section listing rules like "Remote Desktop (TCP-In)" and "Allow All Inbound (DANGER)". A WARNING FINDINGS section follows with disabled security rules and public-profile issues.

  • Verify the report file exists inside the container by running:
ls /workspace/audit-report.txt

You should see /workspace/audit-report.txt listed in the output.

  • Now verify the file persists on your Mac. Open a new terminal tab (or open Finder on macOS) and navigate to your ~/firewall-analyzer folder.
  • Confirm that audit-report.txt appears alongside firewall-rules.json and FirewallAnalyzer.ps1.

Why does the file show up on my Mac?

The volume mount you set up in Step 1 maps your Mac's ~/firewall-analyzer folder to /workspace inside the container. Any file the container writes to /workspace immediately appears on your Mac. This means the report persists even after you stop the container.

Report not generated or file missing?

  • If you see an error about the here-string (@"), make sure the opening @" is on its own line with nothing after it, and the closing "@ starts at column 1 (no leading spaces).
  • If the file is missing from your Mac, confirm you launched the container with the volume mount (-v flag). Without it, writes stay inside the ephemeral container filesystem.
  • If you see "Cannot bind argument to parameter 'Findings'" the audit returned no results. Check that firewall-rules.json exists at /workspace/firewall-rules.json inside the container.

Help me debug my audit report generation

✔️ Awesome, I've got everything!

Great. Double check that you have saved FirewallAnalyzer.ps1 and that audit-report.txt is visible in your ~/firewall-analyzer folder on your Mac.

ⓧ I'd like to double check the full code

# FirewallAnalyzer.ps1
# Windows Firewall Rule Analyzer
# Analyzes firewall rules exported in Get-NetFirewallRule JSON format

function Get-FirewallRules {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Path
    )
    if (-not (Test-Path $Path)) {
        Write-Error "File not found: $Path"
        return
    }
    $rules = Get-Content -Path $Path -Raw | ConvertFrom-Json
    Write-Host "Loaded $($rules.Count) firewall rules from $Path" -ForegroundColor Green
    return $rules
}

function Find-FirewallRule {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Rules,
        [string]$Direction,
        [string]$Action,
        [string]$Protocol,
        [string]$Profile,
        [Nullable[bool]]$Enabled
    )
    $filtered = $Rules
    if ($Direction) { $filtered = $filtered | Where-Object { $_.Direction -eq $Direction } }
    if ($Action) { $filtered = $filtered | Where-Object { $_.Action -eq $Action } }
    if ($Protocol) { $filtered = $filtered | Where-Object { $_.Protocol -eq $Protocol } }
    if ($Profile) { $filtered = $filtered | Where-Object { $_.Profile -like "*$Profile*" } }
    if ($null -ne $Enabled) { $filtered = $filtered | Where-Object { $_.Enabled -eq $Enabled } }
    return $filtered
}

function Get-RuleSummary {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Rules
    )
    Write-Host "`n=== FIREWALL RULE SUMMARY ===" -ForegroundColor Cyan
    Write-Host "Total Rules: $($Rules.Count)"
    Write-Host ""
    $groups = $Rules | Group-Object -Property Direction, Action
    foreach ($group in $groups) {
        Write-Host "  $($group.Name): $($group.Count)" -ForegroundColor Yellow
    }
    Write-Host ""
    $enabledCount = ($Rules | Where-Object { $_.Enabled -eq $true }).Count
    $disabledCount = ($Rules | Where-Object { $_.Enabled -eq $false }).Count
    Write-Host "  Enabled: $enabledCount | Disabled: $disabledCount"
    Write-Host ""
}

function Invoke-FirewallAudit {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Rules
    )
    $findings = @()
    $sensitivePorts = @("3389", "445", "1433", "23", "21")
    foreach ($rule in $Rules) {
        if ($rule.Action -eq "Allow" -and $rule.Direction -eq "Inbound" -and $rule.RemoteAddress -eq "Any" -and $rule.LocalPort -in $sensitivePorts -and $rule.Enabled -eq $true) {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Critical"; Finding = "Sensitive port $($rule.LocalPort) is open to all remote addresses"; Recommendation = "Restrict RemoteAddress to specific IP ranges or subnets" }
        }
        if ($rule.Action -eq "Allow" -and $rule.Protocol -eq "Any" -and $rule.LocalPort -eq "Any" -and $rule.Enabled -eq $true) {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Critical"; Finding = "Rule allows ALL protocols and ALL ports"; Recommendation = "Remove or restrict this rule to specific protocols and ports" }
        }
        if ($rule.Action -eq "Block" -and $rule.Group -eq "Security Rules" -and $rule.Enabled -eq $false) {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Warning"; Finding = "Security block rule is DISABLED"; Recommendation = "Enable this rule to enforce the intended security policy" }
        }
        if ($rule.Action -eq "Allow" -and $rule.Direction -eq "Inbound" -and $rule.Profile -like "*Public*" -and $rule.RemoteAddress -eq "Any" -and $rule.Enabled -eq $true -and $rule.Protocol -ne "ICMPv4") {
            $findings += [PSCustomObject]@{ RuleName = $rule.DisplayName; Severity = "Warning"; Finding = "Inbound allow rule active on Public profile with no address restriction"; Recommendation = "Review if this rule should apply to Public networks; restrict if possible" }
        }
    }
    return $findings
}

function New-AuditReport {
    param(
        [Parameter(Mandatory = $true)]
        [array]$Findings,
        [string]$OutputPath
    )
    $criticalCount = ($Findings | Where-Object { $_.Severity -eq "Critical" }).Count
    $warningCount = ($Findings | Where-Object { $_.Severity -eq "Warning" }).Count
    $infoCount = ($Findings | Where-Object { $_.Severity -eq "Info" }).Count
    $totalFindings = $Findings.Count
    $riskScore = ($criticalCount * 10) + ($warningCount * 5) + ($infoCount * 1)
    $report = @"
=====================================
  WINDOWS FIREWALL SECURITY AUDIT
  Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss")
=====================================

SUMMARY
-------
Total Findings: $totalFindings
  Critical: $criticalCount
  Warning:  $warningCount
  Info:     $infoCount

Risk Score: $riskScore / 100
Rating: $(if ($riskScore -ge 50) { "HIGH RISK" } elseif ($riskScore -ge 25) { "MEDIUM RISK" } else { "LOW RISK" })

"@
    $severityOrder = @("Critical", "Warning", "Info")
    foreach ($severity in $severityOrder) {
        $severityFindings = $Findings | Where-Object { $_.Severity -eq $severity }
        if ($severityFindings.Count -gt 0) {
            $report += "--- $($severity.ToUpper()) FINDINGS ---`n`n"
            foreach ($finding in $severityFindings) {
                $report += "  Rule: $($finding.RuleName)`n"
                $report += "  Issue: $($finding.Finding)`n"
                $report += "  Action: $($finding.Recommendation)`n"
                $report += "`n"
            }
        }
    }
    $report += "=====================================`n"
    $report += "  END OF REPORT`n"
    $report += "=====================================`n"
    Write-Host $report
    if ($OutputPath) {
        $report | Out-File -FilePath $OutputPath -Encoding UTF8
        Write-Host "Report saved to: $OutputPath" -ForegroundColor Green
    }
    return $report
}

Write-Host "Windows Firewall Rule Analyzer" -ForegroundColor Cyan
Write-Host "==============================" -ForegroundColor Cyan
Write-Host ""
$rules = Get-FirewallRules -Path "/workspace/firewall-rules.json"
Get-RuleSummary -Rules $rules
Write-Host "Running security audit..." -ForegroundColor Yellow
$findings = Invoke-FirewallAudit -Rules $rules
New-AuditReport -Findings $findings -OutputPath "/workspace/audit-report.txt"

Your analyzer is complete. You now have a portable security auditing toolkit that runs inside a Docker container, reads real firewall rule data, identifies vulnerabilities, and exports a formatted report. Next up is a Secret Mission where you will build a rule comparison tool that diffs two rule sets.

Secret mission

Build a Rule Set Comparison Tool

Your audit report shows the current state of the firewall. But in real-world security operations, knowing what changed between two points in time is just as important as knowing what exists now. Can you build a tool that diffs two firewall rule sets and reports exactly what was added, removed, and modified?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using Docker, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Running Docker container (PowerShell in the .NET SDK image).
  • Docker image mcr.microsoft.com/dotnet/sdk:9.0 stored locally.
  • Local project folder ~/firewall-analyzer with all scripts and reports.

Keep everything running

No action needed. Choose this if you're still actively building or want to experiment with additional firewall rule scenarios.

  • Your container stays running and your scripts remain at ~/firewall-analyzer.
  • You can re-enter the container at any time or point the analyzer at new JSON exports from real Windows servers.

Pause - I'll come back to this later

Stop the running container to free up memory. Your project files stay on disk so you can pick up where you left off.

  • If you are still inside the PowerShell container, type exit and press Enter to stop it.
  • Your scripts and reports remain in ~/firewall-analyzer on your Mac.
  • To resume later, start the container again with the same volume mount command you used in Step 1.

Delete - I don't want to use this again

Remove all project resources and start fresh. This frees disk space used by the Docker image and project files.

  • If you are still inside the container, type exit to stop it.
  • Find and remove the stopped container by running these commands in your Mac terminal:
docker ps -a --filter ancestor=mcr.microsoft.com/dotnet/sdk:9.0 --format "{{.ID}}"
  • Copy the container ID from the output, then remove it:
docker rm <container-id>
  • Remove the Docker image to free disk space:
docker rmi mcr.microsoft.com/dotnet/sdk:9.0
  • Delete the project folder and all its contents:
rm -rf ~/firewall-analyzer
  • Confirm the folder is gone by running:
ls ~/firewall-analyzer

You should see a "No such file or directory" message confirming the folder has been removed.

Nice Work!

Nice Work!

You just built a portable security analysis toolkit from scratch. Your PowerShell Firewall Rule Analyzer runs inside a Docker container, processes realistic Windows Firewall data, and produces actionable audit reports.

You've learned how to:

  • Run a PowerShell environment inside a Docker container on macOS with a volume mount for persistent scripts.
  • Build PowerShell functions to load, query, filter, and audit Windows Firewall rules structured identically to Get-NetFirewallRule output.
  • Generate a formatted security audit report that identifies critical vulnerabilities like open sensitive ports, overly permissive rules, and disabled security blocks.
  • Secret Mission: Build a rule comparison tool that diffs two firewall configurations and reports added, removed, and modified rules with property-level detail.

Ready to quiz yourself?