Private/Kinds/Firewall.ps1

# The Firewall Kind: what the Windows Firewall of this machine is set to, and what it
# lets out.
#
# Three things a Technician asks of it. Is it on, in the profile that applies right now.
# As what did Windows class the network - a machine of a domain that did not recognise
# its domain gets the stricter profile, and remote support and its shares stop working
# with nothing broken. And which rules are in force that Windows did not bring: what a
# Group Policy, an installer or somebody by hand added - a program that cannot reach its
# Server on a network in order is sometimes one of them.
#
# A rule Windows brought is told by its group: Windows names the group of each of its
# own rules by a reference into its own files ("@FirewallAPI.dll,-28752"), and the rules
# it writes for every Store app of every account carry that account as their owner.
# Observed 2026-10-13 on a workstation of a domain: 621 rules in force, 503 of them with
# such a group, 60 more with an owner. A rule from a Group Policy is none of Windows'
# own, whatever group it has: somebody decided it.
#
# Read with the cmdlets of the NetSecurity module, from the store that holds what is in
# force (ActiveStore): what a Group Policy set and what was set on the machine, merged
# the way Windows merges them. Observed 2026-10-13 on a workstation of a domain: all of
# it readable without admin rights, the profiles in two seconds, the rules in under one.

function Get-FirewallData {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $maximum = [int](Get-Parameter $Parameters 'MaximumRules' 300)

    # The service: without it no rule is in force, and the cmdlets below have no answer.
    $service = ''
    try { $service = "$((Get-Service -Name 'mpssvc' -ErrorAction Stop).Status)" } catch { $service = '' }

    # As Windows says them, undecoded. What is on and what counts is the Judge's call.
    $profiles = @()
    try {
        $profiles = @(Get-NetFirewallProfile -PolicyStore ActiveStore -ErrorAction Stop | ForEach-Object {
            [pscustomobject]@{
                Name       = "$($_.Name)"
                Enabled    = "$($_.Enabled)"
                Inbound    = "$($_.DefaultInboundAction)"
                Outbound   = "$($_.DefaultOutboundAction)"
                LocalRules = "$($_.AllowLocalFirewallRules)"
                LogBlocked = "$($_.LogBlocked)"
            }
        })
    }
    catch { $profiles = @() }

    # As what Windows classed each connected network: that picks the profile.
    $connections = @()
    $connectionsRead = $true
    try {
        $connections = @(Get-NetConnectionProfile -ErrorAction Stop | ForEach-Object {
            [pscustomobject]@{ Alias = "$($_.InterfaceAlias)"; Network = "$($_.Name)"; Category = "$($_.NetworkCategory)" }
        })
    }
    catch { $connectionsRead = $false }

    $joined = $null
    try { $joined = [bool](Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).PartOfDomain } catch { $joined = $null }

    # Another product that registered as the firewall. A server has no such register.
    $products = @()
    try {
        $products = @(Get-CimInstance -Namespace root/SecurityCenter2 -ClassName FirewallProduct -ErrorAction Stop |
            ForEach-Object { [pscustomobject]@{ DisplayName = "$($_.displayName)" } })
    }
    catch { $products = @() }

    # The rules in force that Windows did not bring, each with what it is limited to.
    # The filters are asked for all of them at once, and found by the rule's id.
    $rules = @()
    $rulesRead = $true
    $ruleCount = $null
    $inboundAllow = $null
    try {
        $every = @(Get-NetFirewallRule -PolicyStore ActiveStore -ErrorAction Stop | Where-Object { "$($_.Enabled)" -eq 'True' })
        $inboundAllow = @($every | Where-Object { "$($_.Direction)" -eq 'Inbound' -and "$($_.Action)" -eq 'Allow' }).Count
        $own = @($every | Where-Object {
            "$($_.PolicyStoreSourceType)" -eq 'GroupPolicy' -or (-not "$($_.Owner)" -and -not "$($_.Group)".StartsWith('@')) })
        $ruleCount = $own.Count
        # What blocks first: that is what a Technician looks for.
        $found = @($own | Sort-Object @{ Expression = { "$($_.Action)" -ne 'Block' } }, @{ Expression = { "$($_.Direction)" } }, DisplayName |
            Select-Object -First $maximum)
        if ($found.Count) {
            $ports = @{}; $addresses = @{}; $programs = @{}; $services = @{}; $users = @{}
            $found | Get-NetFirewallPortFilter        -ErrorAction SilentlyContinue | ForEach-Object { $ports["$($_.InstanceID)"] = $_ }
            $found | Get-NetFirewallAddressFilter     -ErrorAction SilentlyContinue | ForEach-Object { $addresses["$($_.InstanceID)"] = $_ }
            $found | Get-NetFirewallApplicationFilter -ErrorAction SilentlyContinue | ForEach-Object { $programs["$($_.InstanceID)"] = $_ }
            $found | Get-NetFirewallServiceFilter     -ErrorAction SilentlyContinue | ForEach-Object { $services["$($_.InstanceID)"] = $_ }
            $found | Get-NetFirewallSecurityFilter    -ErrorAction SilentlyContinue | ForEach-Object { $users["$($_.InstanceID)"] = $_ }
            $rules = @($found | ForEach-Object {
                $id = "$($_.InstanceID)"
                [pscustomobject]@{
                    Name          = "$($_.DisplayName)"
                    Direction     = "$($_.Direction)"
                    Action        = "$($_.Action)"
                    Profile       = "$($_.Profile)"
                    Source        = "$($_.PolicyStoreSourceType)"
                    Protocol      = "$($ports[$id].Protocol)"
                    LocalPort     = (@($ports[$id].LocalPort) | Where-Object { $_ }) -join ', '
                    RemotePort    = (@($ports[$id].RemotePort) | Where-Object { $_ }) -join ', '
                    RemoteAddress = (@($addresses[$id].RemoteAddress) | Where-Object { $_ }) -join ', '
                    Program       = "$($programs[$id].Program)"
                    Package       = "$($programs[$id].Package)"
                    Service       = "$($services[$id].Service)"
                    # Whether it is limited to accounts, and not to which: no name is read.
                    LimitedToUser = [bool]("$($users[$id].LocalUser)" -and "$($users[$id].LocalUser)" -ne 'Any')
                }
            })
        }
    }
    catch { $rulesRead = $false }

    [pscustomobject]@{
        PSTypeName      = 'Gutcheck.Data.Firewall'
        Service         = $service
        Profiles        = $profiles
        Connections     = $connections
        ConnectionsRead = $connectionsRead
        Joined          = $joined
        Products        = $products
        Rules           = $rules
        # An empty list of rules is none only where they were read.
        RulesRead       = $rulesRead
        # How many there are; Rules holds no more than MaximumRules of them.
        RuleCount       = $ruleCount
        InboundAllow    = $inboundAllow
    }
}

function ConvertTo-FirewallFlag {
    <#
    .SYNOPSIS
        What Windows says of a setting of a profile as yes, no, or $null where it says
        neither ("NotConfigured"). Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Text)

    switch ("$Text".Trim()) {
        'True'  { return $true }
        'False' { return $false }
    }
    $null
}

function Get-FirewallActive {
    <#
    .SYNOPSIS
        The profiles that apply right now, each with the adapters it applies to. Pure.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    $active = [ordered]@{}
    foreach ($one in @((Get-DataCollection $Data 'Connections') | Where-Object { $_ })) {
        $name = switch ("$(Get-DataProperty $one 'Category')") {
            'DomainAuthenticated' { 'Domain' }
            'Private'             { 'Private' }
            'Public'              { 'Public' }
            default               { '' }
        }
        if (-not $name) { continue }
        if (-not $active.Contains($name)) { $active[$name] = @() }
        $active[$name] += "$(Get-DataProperty $one 'Alias')"
    }
    $active
}

function Get-FirewallProfileText {
    # The name of a profile as the Report says it. Pure.
    [CmdletBinding()]
    param([AllowNull()][string]$Name)

    switch ("$Name") {
        'Domain'  { return (Get-Text 'Value.Firewall.Profile.Domain') }
        'Private' { return (Get-Text 'Value.Firewall.Profile.Private') }
        'Public'  { return (Get-Text 'Value.Firewall.Profile.Public') }
    }
    "$Name"
}

function New-FirewallStateFinding {
    <#
    .SYNOPSIS
        Whether the Windows Firewall is on, per profile, and which profile applies.
    .DESCRIPTION
        A WARN where the profile that applies is off and no other product registered as
        the firewall, or where it is on and lets in whatever no rule blocks. A profile
        that is off and does not apply is said, and no more: it is not what this machine
        runs on today.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    $check    = Get-Text 'Check.Firewall.State'
    $profiles = @((Get-DataCollection $Data 'Profiles') | Where-Object { $_ })
    $products = @((Get-DataCollection $Data 'Products') | ForEach-Object { "$(Get-DataProperty $_ 'DisplayName')".Trim() } |
        Where-Object { $_ } | Select-Object -Unique)
    $other = ''
    if ($products.Count) { $other = (Get-Text 'Value.Firewall.Product') -f ($products -join ', ') }

    if (-not $profiles.Count) {
        $service = "$(Get-DataProperty $Data 'Service')"
        if (-not $service -or $service -eq 'Running') {
            return New-UnavailableFinding -Category Security -Check $check -Hint (Get-Text 'Hint.Firewall.NotRead')
        }
        $value = Get-Text 'Value.Firewall.ServiceStopped'
        if ($other) {
            return New-Finding -Category Security -Check $check -Severity INFO -Value ('{0} | {1}' -f $value, $other) `
                -Meaning (Get-Text 'Meaning.Firewall.OffOther') -Hint (Get-Text 'Hint.Firewall.OffOther')
        }
        return New-Finding -Category Security -Check $check -Severity WARN -Value $value `
            -Meaning (Get-Text 'Meaning.Firewall.ServiceStopped') -Hint (Get-Text 'Hint.Firewall.Off')
    }

    $active = Get-FirewallActive -Data $Data
    $states = @($profiles | ForEach-Object {
        $on = ConvertTo-FirewallFlag (Get-DataProperty $_ 'Enabled')
        $state = if ($null -eq $on) { Get-Text 'Value.Firewall.Unknown' } elseif ($on) { Get-Text 'Value.Firewall.On' } else { Get-Text 'Value.Firewall.Off' }
        '{0} {1}' -f (Get-FirewallProfileText -Name "$(Get-DataProperty $_ 'Name')"), $state
    })
    $value = @((Get-Text 'Value.Firewall.Profiles') -f ($states -join '; '))
    if ($active.Count) {
        $value += (Get-Text 'Value.Firewall.Active') -f (@($active.Keys | ForEach-Object {
            '{0} ({1})' -f (Get-FirewallProfileText -Name $_), (@($active[$_]) -join ', ') }) -join ', ')
    }
    elseif ((Get-DataProperty $Data 'ConnectionsRead') -ne $false) { $value += Get-Text 'Value.Firewall.ActiveNone' }
    if ($other) { $value += $other }

    $off       = @($profiles | Where-Object { (ConvertTo-FirewallFlag (Get-DataProperty $_ 'Enabled')) -eq $false } | ForEach-Object { "$(Get-DataProperty $_ 'Name')" })
    $offActive = @($off | Where-Object { $active.Contains($_) })
    $open      = @($profiles | Where-Object {
        $name = "$(Get-DataProperty $_ 'Name')"
        $active.Contains($name) -and $off -notcontains $name -and "$(Get-DataProperty $_ 'Inbound')" -eq 'Allow' } |
        ForEach-Object { Get-FirewallProfileText -Name "$(Get-DataProperty $_ 'Name')" })
    if ($open.Count) { $value += (Get-Text 'Value.Firewall.InboundOpen') -f ($open -join ', ') }

    $reference = @(New-Reference -Section 'Title.Firewall.Profiles')
    $allowed = ConvertTo-Number (Get-DataProperty $Data 'InboundAllow')
    if ($null -ne $allowed) { $value += (Get-Text 'Value.Firewall.InboundAllow') -f $allowed }
    $rules = @((Get-DataCollection $Data 'Rules') | Where-Object { $_ })
    if ($rules.Count) {
        $count = ConvertTo-Number (Get-DataProperty $Data 'RuleCount')
        if ($null -eq $count -or $count -lt $rules.Count) { $count = $rules.Count }
        $blocking = @($rules | Where-Object { "$(Get-DataProperty $_ 'Direction')" -eq 'Outbound' -and "$(Get-DataProperty $_ 'Action')" -eq 'Block' })
        $value += (Get-Text 'Value.Firewall.OwnRules') -f $count, $blocking.Count
        if ($count -gt $rules.Count) { $value += (Get-Text 'Value.Firewall.RulesShown') -f $rules.Count }
        $reference += New-Reference -Section 'Title.Firewall.Rules'
    }
    elseif ((Get-DataProperty $Data 'RulesRead') -eq $false) { $value += Get-Text 'Value.Firewall.RulesNotRead' }

    $severity = 'OK'; $meaning = ''; $hint = ''
    if ($offActive.Count -and -not $other) {
        $severity = 'WARN'; $meaning = Get-Text 'Meaning.Firewall.Off'; $hint = Get-Text 'Hint.Firewall.Off'
    }
    elseif ($off.Count -and $other) {
        $severity = 'INFO'; $meaning = Get-Text 'Meaning.Firewall.OffOther'; $hint = Get-Text 'Hint.Firewall.OffOther'
    }
    elseif ($open.Count) {
        $severity = 'WARN'; $meaning = Get-Text 'Meaning.Firewall.InboundOpen'; $hint = Get-Text 'Hint.Firewall.InboundOpen'
    }
    elseif ($off.Count) {
        $severity = 'INFO'; $meaning = Get-Text 'Meaning.Firewall.OffInactive'; $hint = Get-Text 'Hint.Firewall.OffInactive'
    }

    New-Finding -Category Security -Check $check -Severity $severity -Value ($value -join ' | ') `
        -Meaning $meaning -Hint $hint -Reference $reference
}

function New-FirewallNetworkFinding {
    <#
    .SYNOPSIS
        As what Windows classed each connected network. Nothing where none is connected
        or that was not read.
    .DESCRIPTION
        A machine of a domain with no network classed as the domain's: right anywhere
        but in the office, and in the office the reason remote support and the machine's
        own shares are blocked. Which of the two it is the machine cannot say, so it is
        said at INFO, with both.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    $connections = @((Get-DataCollection $Data 'Connections') | Where-Object { $_ })
    if (-not $connections.Count) { return }

    $named = @($connections | ForEach-Object {
        $category = switch ("$(Get-DataProperty $_ 'Category')") {
            'DomainAuthenticated' { Get-Text 'Value.Firewall.Category.Domain' }
            'Private'             { Get-Text 'Value.Firewall.Category.Private' }
            'Public'              { Get-Text 'Value.Firewall.Category.Public' }
            default               { Get-Text 'Value.Firewall.Unknown' }
        }
        (Get-Text 'Value.Firewall.Network') -f "$(Get-DataProperty $_ 'Alias')", $category
    })
    $value = (Get-Text 'Value.Firewall.Networks') -f ($named -join '; ')
    $check = Get-Text 'Check.Firewall.Network'
    $reference = @(New-Reference -Section 'Title.Firewall.Profiles')

    $inDomain = @($connections | Where-Object { "$(Get-DataProperty $_ 'Category')" -eq 'DomainAuthenticated' })
    if ((Get-DataProperty $Data 'Joined') -eq $true -and -not $inDomain.Count) {
        return New-Finding -Category Security -Check $check -Severity INFO -Value $value `
            -Meaning (Get-Text 'Meaning.Firewall.NotDomain') -Hint (Get-Text 'Hint.Firewall.NotDomain') -Reference $reference
    }
    New-Finding -Category Security -Check $check -Severity OK -Value $value -Reference $reference
}

function ConvertTo-FirewallFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    New-FirewallStateFinding -Data $Data
    New-FirewallNetworkFinding -Data $Data
}

function ConvertTo-FirewallSection {
    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()]$Data)

    $profiles = @((Get-DataCollection $Data 'Profiles') | Where-Object { $_ })
    if ($profiles.Count) {
        $active = Get-FirewallActive -Data $Data
        New-Section -Title (Get-Text 'Title.Firewall.Profiles') -Help (Get-Text 'Help.Firewall.Profiles') -Row @(
            $profiles | ForEach-Object {
                $name = "$(Get-DataProperty $_ 'Name')"
                # Where Windows says neither, its own default holds: in is blocked, out is not.
                $in  = if ("$(Get-DataProperty $_ 'Inbound')" -eq 'Allow')  { Get-Text 'Value.Firewall.Action.Allow' } else { Get-Text 'Value.Firewall.Action.Block' }
                $out = if ("$(Get-DataProperty $_ 'Outbound')" -eq 'Block') { Get-Text 'Value.Firewall.Action.Block' } else { Get-Text 'Value.Firewall.Action.Allow' }
                $applies = Get-Text 'Value.Firewall.NotApplied'
                if ($active.Contains($name)) { $applies = @($active[$name]) -join ', ' }
                $local = ConvertTo-FirewallFlag (Get-DataProperty $_ 'LocalRules')
                if ($null -eq $local) { $local = $true }
                $row = [ordered]@{}
                $row[(Get-Text 'Column.Firewall.Profile')]    = Get-FirewallProfileText -Name $name
                $row[(Get-Text 'Column.Firewall.AppliesTo')]  = $applies
                $row[(Get-Text 'Column.Firewall.Enabled')]    = Get-TriStateText (ConvertTo-FirewallFlag (Get-DataProperty $_ 'Enabled'))
                $row[(Get-Text 'Column.Firewall.Inbound')]    = $in
                $row[(Get-Text 'Column.Firewall.Outbound')]   = $out
                $row[(Get-Text 'Column.Firewall.LocalRules')] = Get-TriStateText $local
                $row[(Get-Text 'Column.Firewall.LogBlocked')] = Get-TriStateText ([bool](ConvertTo-FirewallFlag (Get-DataProperty $_ 'LogBlocked')))
                [pscustomobject]$row
            }
        )
    }

    $rules = @((Get-DataCollection $Data 'Rules') | Where-Object { $_ })
    if ($rules.Count) {
        $any = Get-Text 'Value.Firewall.Any'
        New-Section -Title (Get-Text 'Title.Firewall.Rules') -Help (Get-Text 'Help.Firewall.Rules') -Row @(
            $rules | ForEach-Object {
                $source = switch ("$(Get-DataProperty $_ 'Source')") {
                    'Local'       { Get-Text 'Value.Firewall.Source.Local' }
                    'GroupPolicy' { Get-Text 'Value.Firewall.Source.GroupPolicy' }
                    default       { (Get-Text 'Value.Firewall.Source.Other') -f "$(Get-DataProperty $_ 'Source')" }
                }
                $in = @("$(Get-DataProperty $_ 'Profile')" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ } |
                    ForEach-Object { if ($_ -eq 'Any') { $any } else { Get-FirewallProfileText -Name $_ } }) -join ', '
                $limits = @()
                $program = "$(Get-DataProperty $_ 'Program')".Trim()
                $service = "$(Get-DataProperty $_ 'Service')".Trim()
                $package = "$(Get-DataProperty $_ 'Package')".Trim()
                if ($program -and $program -ne 'Any') { $limits += (Get-Text 'Value.Firewall.Limit.Program') -f $program }
                if ($service -and $service -ne 'Any') { $limits += (Get-Text 'Value.Firewall.Limit.Service') -f $service }
                if ($package -and $package -ne 'Any') { $limits += Get-Text 'Value.Firewall.Limit.Package' }
                if ([bool](Get-DataProperty $_ 'LimitedToUser')) { $limits += Get-Text 'Value.Firewall.Limit.User' }
                if (-not $limits.Count) { $limits += Get-Text 'Value.Firewall.Limit.None' }
                $all = { param($Text) if (-not "$Text" -or "$Text" -eq 'Any') { $any } else { "$Text" } }
                $row = [ordered]@{}
                $direction = if ("$(Get-DataProperty $_ 'Direction')" -eq 'Outbound') { Get-Text 'Value.Firewall.Direction.Out' } else { Get-Text 'Value.Firewall.Direction.In' }
                $effect    = if ("$(Get-DataProperty $_ 'Action')" -eq 'Block') { Get-Text 'Value.Firewall.Effect.Block' } else { Get-Text 'Value.Firewall.Effect.Allow' }
                $row[(Get-Text 'Column.Firewall.Rule')]          = "$(Get-DataProperty $_ 'Name')"
                $row[(Get-Text 'Column.Firewall.Direction')]     = $direction
                $row[(Get-Text 'Column.Firewall.Effect')]        = $effect
                $row[(Get-Text 'Column.Firewall.Source')]        = $source
                $row[(Get-Text 'Column.Firewall.Profile')]       = $in
                $row[(Get-Text 'Column.Firewall.Protocol')]      = & $all (Get-DataProperty $_ 'Protocol')
                $row[(Get-Text 'Column.Firewall.LocalPort')]     = & $all (Get-DataProperty $_ 'LocalPort')
                $row[(Get-Text 'Column.Firewall.RemotePort')]    = & $all (Get-DataProperty $_ 'RemotePort')
                $row[(Get-Text 'Column.Firewall.RemoteAddress')] = & $all (Get-DataProperty $_ 'RemoteAddress')
                $row[(Get-Text 'Column.Firewall.Limit')]         = $limits -join ', '
                [pscustomobject]$row
            }
        )
    }
}