Public/common.ps1

# NetworkSecurityAudit — any OS.

function Test-NsaTcpPort {
    <#
    .SYNOPSIS
    Probes TCP ports in parallel. State is Open (connected), Closed (refused — the host
    answered with RST), Filtered (no answer before the timeout — typically a firewall
    dropping packets), or a socket error such as HostUnreachable.
    .PARAMETER Open
    Return only Open ports.
    .PARAMETER Responding
    Return only ports that answered (Open or Closed) — leaves out Filtered.
    .EXAMPLE
    Test-NsaTcpPort 192.0.2.10 -Port ((1..1024) + 42777) -Open
    .EXAMPLE
    Test-NsaTcpPort 'fe80::1234:5678:9abc:def0%30' -Port 22, 42777
    .NOTES
    Windows retries the SYN before reporting a refused connection, so from a Windows machine
    a closed port takes about 2 seconds to show as Closed; with a shorter timeout it shows as
    Filtered. Hence the 2500 ms default there.
    A Windows target with its firewall on answers closed ports with nothing at all (stealth
    mode), even where a rule allows them: Filtered then does not mean "blocked". To show that
    the firewall blocks something, test a port that is listening inside.
    #>

    param(
        [Parameter(Mandatory)][string]$ComputerName,
        # object[] so nested lists such as (135, 445, (49664..49670)) are accepted and flattened.
        [object[]]$Port = (1..1024),
        [int]$TimeoutMs = $(if ($IsWindows) { 2500 } else { 1500 }),
        [int]$ThrottleLimit = 128,
        [switch]$Open,
        [switch]$Responding
    )
    $Port = [int[]]@($Port | ForEach-Object { $_ })
    $ip = $null
    $target = if ([System.Net.IPAddress]::TryParse($ComputerName, [ref]$ip)) { $ip } else { $ComputerName }
    $result = $Port | Sort-Object -Unique | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
        $t = $using:target
        $c = if ($t -is [System.Net.IPAddress]) { [System.Net.Sockets.TcpClient]::new($t.AddressFamily) } else { [System.Net.Sockets.TcpClient]::new() }
        $state = 'Filtered'
        try {
            if ($c.ConnectAsync($t, $_).Wait($using:TimeoutMs)) { $state = 'Open' }
        } catch {
            $e = $_.Exception.GetBaseException()
            $state = if ($e -is [System.Net.Sockets.SocketException]) {
                switch ($e.SocketErrorCode) { 'ConnectionRefused' { 'Closed' } 'TimedOut' { 'Filtered' } default { "$($e.SocketErrorCode)" } }
            } else { 'Error' }
        } finally { $c.Dispose() }
        [pscustomobject]@{ ComputerName = "$t"; Port = $_; State = $state }
    } | Sort-Object Port
    if ($Open) { $result | Where-Object State -eq 'Open' }
    elseif ($Responding) { $result | Where-Object State -in 'Open', 'Closed' }
    else { $result }
}

function Get-NsaListeningPort {
    <#
    .SYNOPSIS
    Lists listening TCP and UDP sockets with process and service. Exposed = bound to
    something other than loopback.
    Windows: ServicesInProcess lists every service hosted in that process. A shared svchost
    hosts several, and only one of them owns the port — such entries end in "(shared)", so do
    not read them as "these services opened the port".
    Linux: ServicesInProcess is the systemd unit of the process.
    Windows: UDP includes many sockets on high ports (49152 and up) bound by clients such as
    the DNS client; they are not services waiting for connections.
    .PARAMETER Sudo
    Linux/macOS: run ss/lsof through `sudo -n` so processes of other users are shown.
    .PARAMETER IncludeLoopback
    Also return sockets bound to loopback only.
    .PARAMETER Protocol
    TCP or UDP only.
    #>

    param([switch]$IncludeLoopback, [switch]$Sudo, [ValidateSet('TCP', 'UDP')][string]$Protocol)

    $isLoopback = { param($a) $a -match '^(127\.|::1$|\[::1\])' -or $a -match '%lo$' }
    $finish = { $input | Where-Object { ($IncludeLoopback -or $_.Exposed) -and (-not $Protocol -or $_.Protocol -eq $Protocol) } | Sort-Object Protocol, Port, Address }

    if ($IsWindows) {
        $svc = Get-CimInstance Win32_Service -Filter 'ProcessId > 0' | Group-Object ProcessId -AsHashTable -AsString
        $procName = @{}
        Get-Process | ForEach-Object { $procName[$_.Id] = $_.ProcessName }
        $rows = @(
            Get-NetTCPConnection -State Listen | ForEach-Object { [pscustomobject]@{ Protocol = 'TCP'; Address = $_.LocalAddress; Port = $_.LocalPort; PID = $_.OwningProcess } }
            Get-NetUDPEndpoint | ForEach-Object { [pscustomobject]@{ Protocol = 'UDP'; Address = $_.LocalAddress; Port = $_.LocalPort; PID = $_.OwningProcess } }
        )
        $rows | ForEach-Object {
            $s = $svc["$($_.PID)"]
            [pscustomobject]@{
                Protocol = $_.Protocol; Port = $_.Port; Exposed = -not (& $isLoopback $_.Address)
                Address  = $_.Address; PID = $_.PID
                Process  = $procName[[int]$_.PID]
                ServicesInProcess = if (-not $s) { '' } elseif (@($s).Count -gt 1) { (($s.Name | Sort-Object) -join ', ') + ' (shared)' } else { $s[0].Name }
            }
        } | & $finish
        return
    }

    if ($IsLinux) {
        $lines = if ($Sudo) { sudo -n ss -H -tulpn } else { ss -H -tulpn }
        & { foreach ($l in $lines) {
            $f = $l -split '\s+'
            if ($f.Count -lt 5) { continue }
            $local = $f[4]
            $i = $local.LastIndexOf(':')
            $addr = $local.Substring(0, $i).Trim('[', ']'); $port = [int]$local.Substring($i + 1)
            $proc = ''; $procId = $null; $unit = ''
            if ($l -match 'users:\(\("([^"]+)",pid=(\d+)') {
                $proc = $Matches[1]; $procId = [int]$Matches[2]
                $cg = Get-Content "/proc/$procId/cgroup" -ErrorAction SilentlyContinue | Select-Object -Last 1
                if ($cg -match '/([^/]+\.(service|socket|scope))$') { $unit = $Matches[1] }
            }
            [pscustomobject]@{
                Protocol = $f[0].ToUpper(); Port = $port; Exposed = -not (& $isLoopback $addr)
                # ss writes "address%interface" for sockets bound to a device; keep the zone only where it matters (fe80::).
                Address = $(if ($addr -notmatch '^fe80') { $addr -replace '%.*' } else { $addr }); PID = $procId; Process = $proc; ServicesInProcess = $unit
            }
        } } | & $finish
        return
    }

    if ($IsMacOS) {
        # Not yet verified on a real Mac.
        $out = if ($Sudo) { @(sudo -n lsof -nP -iTCP -sTCP:LISTEN 2>$null) + @(sudo -n lsof -nP -iUDP 2>$null) }
               else { @(lsof -nP -iTCP -sTCP:LISTEN 2>$null) + @(lsof -nP -iUDP 2>$null) }
        & { foreach ($l in $out | Where-Object { $_ -notmatch '^COMMAND' }) {
            $f = $l -split '\s+'
            if ($f.Count -lt 9) { continue }
            $name = $f[8]; $i = $name.LastIndexOf(':')
            if ($i -lt 0 -or $name -match '->') { continue }   # connected UDP sockets are not listeners
            $addr = $name.Substring(0, $i).Trim('[', ']')
            [pscustomobject]@{
                Protocol = $f[7]; Port = [int]$name.Substring($i + 1); Exposed = -not (& $isLoopback $addr)
                Address = $addr; PID = [int]$f[1]; Process = $f[0]; ServicesInProcess = ''
            }
        } } | & $finish
    }
}