modules/Network/Network.psm1
|
<#
Network.psm1 Ultimate Modular Windows Server Discovery Toolkit - Network collector module. Owns datasets: NetworkAdapters, IPConfiguration, Routes, DnsClient, ListeningPorts, EstablishedConnections, FirewallRules. Design rules: - Windows PowerShell 5.1 compatible. No PS7-only syntax. - STRICTLY READ-ONLY. Only Get-*/query cmdlets and read-only external tools (netstat -ano, route print) are used. Nothing here changes the system. - Defensive: every collection block is wrapped in try/catch so a single failure never stops the module. Missing cmdlets fall back to CIM / CLI. - Emits canonical dataset field names so the RiskEngine rules can key off them. NOTE: StrictMode is intentionally NOT enabled (dynamic CIM / .NET objects). #> #region Metadata & prerequisites ---------------------------------------------- function Get-DiscoveryModuleMetadata { [pscustomobject]@{ ModuleName = 'Network' DisplayName = 'Network Configuration & Connections' Category = 'Network' Version = '1.0.0' DefaultInFast = $true DefaultInDeep = $true RequiresAdmin = $false RequiresDomainContext = $false RequiresRole = $null EstimatedImpact = 'Low' CanRunAsSystem = $true ProducesDatasets = @('NetworkAdapters','IPConfiguration','Routes','DnsClient','ListeningPorts','EstablishedConnections','FirewallRules') ProducesRisks = $true ProducesFollowUpQuestions = $true SupportsDeepMode = $true SupportsComplianceLens = $false } } function Test-DiscoveryPrerequisites { param([object]$Context) # Network telemetry is available on every Windows host (cmdlets or CIM/CLI # fallbacks). No role/admin gate is required to run this collector. [pscustomobject]@{ ModuleName='Network'; CanRun=$true; Status='Ready'; Reason=''; Limitations=@() } } #endregion #region Shared helpers -------------------------------------------------------- function Get-NwProcessMap { <# Builds a best-effort ProcessId(int) -> ProcessName map. Never throws. #> param() $map = @{} try { foreach ($p in (Get-Process -ErrorAction Stop)) { try { $map[[int]$p.Id] = [string]$p.ProcessName } catch { } } } catch { } return $map } function Get-NwServiceMap { <# Builds a best-effort ProcessId(int) -> 'svc1,svc2' service-name map. #> param([object]$Context) $map = @{} try { $svcs = Invoke-CimSafe -ClassName 'Win32_Service' -Property @('Name','ProcessId','State') foreach ($s in $svcs) { try { $procId = 0 if ($null -ne $s.ProcessId) { $procId = [int]$s.ProcessId } if ($procId -gt 0 -and $s.Name) { if ($map.ContainsKey($procId)) { $map[$procId] = ($map[$procId] + ',' + [string]$s.Name) } else { $map[$procId] = [string]$s.Name } } } catch { } } } catch { } return $map } function Split-NwEndpoint { <# Splits 'addr:port' (incl. bracketed IPv6 '[::]:445') into address/port. #> param([string]$Endpoint) $result = @{ Address = ''; Port = '' } if ([string]::IsNullOrWhiteSpace($Endpoint)) { return $result } $idx = $Endpoint.LastIndexOf(':') if ($idx -lt 0) { $result.Address = $Endpoint; return $result } $addr = $Endpoint.Substring(0, $idx) $port = $Endpoint.Substring($idx + 1) $addr = $addr.Trim('[').Trim(']') $result.Address = $addr $result.Port = $port return $result } function ConvertTo-NwPrefixLength { <# Converts a dotted IPv4 subnet mask (255.255.255.0) to a prefix length. #> param([string]$Mask) try { if ([string]::IsNullOrWhiteSpace($Mask)) { return $null } $octets = $Mask.Split('.') if ($octets.Count -ne 4) { return $null } $bits = 0 foreach ($o in $octets) { $n = 0 if (-not [int]::TryParse($o, [ref]$n)) { return $null } $binary = [Convert]::ToString($n, 2) foreach ($ch in $binary.ToCharArray()) { if ($ch -eq '1') { $bits++ } } } return $bits } catch { return $null } } function Get-NwNetstatRows { <# READ-ONLY fallback: parses 'netstat -ano' into structured rows when the Get-NetTCPConnection / Get-NetUDPEndpoint cmdlets are unavailable. #> param([object]$Context) $rows = [System.Collections.Generic.List[object]]::new() try { $res = Invoke-CommandLineSafe -FilePath 'netstat.exe' -Arguments @('-ano') -TimeoutSeconds 45 if (-not $res -or -not $res.StdOut) { return ,@($rows) } $lines = $res.StdOut -split "`r?`n" foreach ($line in $lines) { $t = $line.Trim() if ([string]::IsNullOrWhiteSpace($t)) { continue } $tokens = @($t -split '\s+' | Where-Object { $_ -ne '' }) if ($tokens.Count -lt 4) { continue } $proto = $tokens[0].ToUpperInvariant() if ($proto -ne 'TCP' -and $proto -ne 'UDP') { continue } $local = $tokens[1] $state = '' $procId = 0 if ($proto -eq 'TCP') { if ($tokens.Count -lt 5) { continue } $state = $tokens[3] [void][int]::TryParse($tokens[4], [ref]$procId) $remote = $tokens[2] } else { # UDP has no state column: PROTO LOCAL FOREIGN PID $remote = $tokens[2] [void][int]::TryParse($tokens[$tokens.Count - 1], [ref]$procId) } $le = Split-NwEndpoint -Endpoint $local $re = Split-NwEndpoint -Endpoint $remote $rows.Add([pscustomobject]@{ Protocol = $proto LocalAddress = $le.Address LocalPort = $le.Port RemoteAddress = $re.Address RemotePort = $re.Port State = $state OwningProcessId = $procId }) } } catch { Write-Log -Level WARN -Message 'netstat parsing failed.' -Module 'Network' -Exception $_ -Context $Context } return ,@($rows) } #endregion #region Collectors ------------------------------------------------------------ function Get-NwAdapterRows { param([object]$Context) $rows = [System.Collections.Generic.List[object]]::new() if (Get-CommandAvailable -Name 'Get-NetAdapter') { try { $adapters = @(Get-NetAdapter -ErrorAction Stop) foreach ($a in $adapters) { try { $isVirtual = $false try { $isVirtual = [bool]$a.Virtual } catch { $isVirtual = $false } $rows.Add([pscustomobject]@{ InterfaceAlias = [string]$a.Name InterfaceDescription = [string]$a.InterfaceDescription MacAddress = [string]$a.MacAddress Status = [string]$a.Status LinkSpeed = [string]$a.LinkSpeed MediaType = [string]$a.MediaType InterfaceIndex = [int]$a.ifIndex DriverVersion = [string]$a.DriverVersion IsVirtual = $isVirtual }) } catch { } } return ,@($rows) } catch { Write-Log -Level WARN -Message 'Get-NetAdapter failed; falling back to CIM.' -Module 'Network' -Exception $_ -Context $Context } } # Fallback: Win32_NetworkAdapter (physical/connected adapters). try { $cim = Invoke-CimSafe -ClassName 'Win32_NetworkAdapter' -Filter 'PhysicalAdapter=TRUE' if (-not $cim -or @($cim).Count -eq 0) { $cim = Invoke-CimSafe -ClassName 'Win32_NetworkAdapter' } foreach ($a in $cim) { try { $speed = $null if ($a.Speed) { try { $speed = [string]([math]::Round(([double]$a.Speed)/1MB,0)) + ' Mbps' } catch { $speed = [string]$a.Speed } } $status = '' switch ([string]$a.NetConnectionStatus) { '2' { $status = 'Up' } '7' { $status = 'Disconnected' } default { $status = [string]$a.NetConnectionStatus } } $rows.Add([pscustomobject]@{ InterfaceAlias = [string]$a.NetConnectionID InterfaceDescription = [string]$a.Name MacAddress = [string]$a.MACAddress Status = $status LinkSpeed = $speed MediaType = [string]$a.AdapterType InterfaceIndex = ($(if ($null -ne $a.InterfaceIndex) { [int]$a.InterfaceIndex } else { -1 })) DriverVersion = '' IsVirtual = $false }) } catch { } } } catch { Write-Log -Level WARN -Message 'Win32_NetworkAdapter fallback failed.' -Module 'Network' -Exception $_ -Context $Context } return ,@($rows) } function Get-NwIpConfigRows { param([object]$Context) $rows = [System.Collections.Generic.List[object]]::new() $haveModern = (Get-CommandAvailable -Name 'Get-NetIPAddress') if ($haveModern) { try { # Build enrichment maps once. $adapterMap = @{} # ifIndex -> adapter object try { foreach ($a in (Get-NetAdapter -ErrorAction Stop)) { $adapterMap[[int]$a.ifIndex] = $a } } catch { } $gatewayMap = @{} # ifIndex -> gateway string try { if (Get-CommandAvailable -Name 'Get-NetRoute') { foreach ($r in (Get-NetRoute -ErrorAction Stop | Where-Object { $_.DestinationPrefix -eq '0.0.0.0/0' -or $_.DestinationPrefix -eq '::/0' })) { $nh = [string]$r.NextHop if ($nh -and $nh -ne '0.0.0.0' -and $nh -ne '::') { $gi = [int]$r.ifIndex if ($gatewayMap.ContainsKey($gi)) { if ($gatewayMap[$gi] -notmatch [regex]::Escape($nh)) { $gatewayMap[$gi] = ($gatewayMap[$gi] + ', ' + $nh) } } else { $gatewayMap[$gi] = $nh } } } } } catch { } $dnsMap = @{} # ifIndex -> dns servers joined try { if (Get-CommandAvailable -Name 'Get-DnsClientServerAddress') { foreach ($d in (Get-DnsClientServerAddress -ErrorAction Stop)) { $servers = @($d.ServerAddresses | Where-Object { $_ }) if ($servers.Count -gt 0) { $di = [int]$d.InterfaceIndex if ($dnsMap.ContainsKey($di)) { $dnsMap[$di] = ($dnsMap[$di] + ', ' + ($servers -join ', ')) } else { $dnsMap[$di] = ($servers -join ', ') } } } } } catch { } $dhcpMap = @{} # ifIndex -> isStatic bool $metricMap = @{} # ifIndex -> interface metric try { if (Get-CommandAvailable -Name 'Get-NetIPInterface') { foreach ($i in (Get-NetIPInterface -AddressFamily IPv4 -ErrorAction Stop)) { $ii = [int]$i.InterfaceIndex $dhcpMap[$ii] = ([string]$i.Dhcp -eq 'Disabled') try { $metricMap[$ii] = [int]$i.InterfaceMetric } catch { } } } } catch { } $suffixMap = @{} # ifAlias -> connection-specific suffix try { if (Get-CommandAvailable -Name 'Get-DnsClient') { foreach ($c in (Get-DnsClient -ErrorAction Stop)) { if ($c.ConnectionSpecificSuffix) { $suffixMap[[string]$c.InterfaceAlias] = [string]$c.ConnectionSpecificSuffix } } } } catch { } # Group IP addresses by interface. $allIps = @(Get-NetIPAddress -ErrorAction Stop | Where-Object { $_.InterfaceAlias -notlike 'Loopback Pseudo-Interface*' }) $groups = $allIps | Group-Object -Property InterfaceIndex foreach ($g in $groups) { try { $ifIndex = [int]$g.Name $alias = [string]($g.Group[0].InterfaceAlias) $v4 = @($g.Group | Where-Object { [string]$_.AddressFamily -eq 'IPv4' }) $v6 = @($g.Group | Where-Object { [string]$_.AddressFamily -eq 'IPv6' }) $v4Addresses = @($v4 | ForEach-Object { [string]$_.IPAddress }) $v6Addresses = @($v6 | ForEach-Object { [string]$_.IPAddress }) # Primary IPv4: prefer a non-APIPA address. $primaryV4 = $null foreach ($a in $v4) { if (([string]$a.IPAddress) -notlike '169.254.*') { $primaryV4 = $a; break } } if (-not $primaryV4 -and $v4.Count -gt 0) { $primaryV4 = $v4[0] } # Primary IPv6: prefer a non-link-local address. $primaryV6 = $null foreach ($a in $v6) { if (([string]$a.IPAddress) -notlike 'fe80:*') { $primaryV6 = $a; break } } if (-not $primaryV6 -and $v6.Count -gt 0) { $primaryV6 = $v6[0] } $prefixLen = $null if ($primaryV4) { try { $prefixLen = [int]$primaryV4.PrefixLength } catch { } } # IsStatic: prefer NetIPInterface DHCP flag, else address PrefixOrigin. $isStatic = $false if ($dhcpMap.ContainsKey($ifIndex)) { $isStatic = [bool]$dhcpMap[$ifIndex] } elseif ($primaryV4) { $po = [string]$primaryV4.PrefixOrigin if ($po -eq 'Manual') { $isStatic = $true } elseif ($po -eq 'Dhcp') { $isStatic = $false } } $mac = '' if ($adapterMap.ContainsKey($ifIndex)) { $mac = [string]$adapterMap[$ifIndex].MacAddress } $desc = '' if ($adapterMap.ContainsKey($ifIndex)) { $desc = [string]$adapterMap[$ifIndex].InterfaceDescription } $gw = '' if ($gatewayMap.ContainsKey($ifIndex)) { $gw = [string]$gatewayMap[$ifIndex] } $dns = '' if ($dnsMap.ContainsKey($ifIndex)) { $dns = [string]$dnsMap[$ifIndex] } $metric = $null if ($metricMap.ContainsKey($ifIndex)) { $metric = $metricMap[$ifIndex] } $suffix = '' if ($suffixMap.ContainsKey($alias)) { $suffix = [string]$suffixMap[$alias] } $rows.Add([pscustomobject]@{ InterfaceAlias = $alias Description = $desc MacAddress = $mac IPv4Address = ($(if ($primaryV4) { [string]$primaryV4.IPAddress } else { '' })) IPv6Address = ($(if ($primaryV6) { [string]$primaryV6.IPAddress } else { '' })) PrefixLength = $prefixLen DefaultGateway = $gw DnsServers = $dns DnsSuffix = $suffix IsStatic = $isStatic InterfaceMetric = $metric AllIPv4Addresses = ($v4Addresses -join ', ') AllIPv6Addresses = ($v6Addresses -join ', ') InterfaceIndex = $ifIndex }) } catch { } } return ,@($rows) } catch { Write-Log -Level WARN -Message 'Get-NetIPAddress path failed; falling back to CIM.' -Module 'Network' -Exception $_ -Context $Context } } # Fallback: Win32_NetworkAdapterConfiguration (IP-enabled only). try { $cfgs = Invoke-CimSafe -ClassName 'Win32_NetworkAdapterConfiguration' -Filter 'IPEnabled=TRUE' foreach ($c in $cfgs) { try { $ipAll = @($c.IPAddress) $v4 = @($ipAll | Where-Object { $_ -and $_ -notmatch ':' }) $v6 = @($ipAll | Where-Object { $_ -and $_ -match ':' }) $prefixLen = $null $subnet = @($c.IPSubnet) if ($subnet.Count -gt 0) { $prefixLen = ConvertTo-NwPrefixLength -Mask ([string]$subnet[0]) } $gw = '' if ($c.DefaultIPGateway) { $gw = (@($c.DefaultIPGateway) -join ', ') } $dns = '' if ($c.DNSServerSearchOrder) { $dns = (@($c.DNSServerSearchOrder) -join ', ') } $isStatic = $false try { $isStatic = (-not [bool]$c.DHCPEnabled) } catch { $isStatic = $false } $rows.Add([pscustomobject]@{ InterfaceAlias = [string]$c.Description Description = [string]$c.Description MacAddress = [string]$c.MACAddress IPv4Address = ($(if ($v4.Count -gt 0) { [string]$v4[0] } else { '' })) IPv6Address = ($(if ($v6.Count -gt 0) { [string]$v6[0] } else { '' })) PrefixLength = $prefixLen DefaultGateway = $gw DnsServers = $dns DnsSuffix = [string]$c.DNSDomain IsStatic = $isStatic InterfaceMetric = $null AllIPv4Addresses = ($v4 -join ', ') AllIPv6Addresses = ($v6 -join ', ') InterfaceIndex = ($(if ($null -ne $c.InterfaceIndex) { [int]$c.InterfaceIndex } else { -1 })) }) } catch { } } } catch { Write-Log -Level WARN -Message 'Win32_NetworkAdapterConfiguration fallback failed.' -Module 'Network' -Exception $_ -Context $Context } return ,@($rows) } function Get-NwRouteRows { param([object]$Context) $rows = [System.Collections.Generic.List[object]]::new() $cap = 1000 if (Get-CommandAvailable -Name 'Get-NetRoute') { try { $routes = @(Get-NetRoute -ErrorAction Stop) $count = 0 foreach ($r in $routes) { if ($count -ge $cap) { break } try { $dest = [string]$r.DestinationPrefix $destAddr = $dest $prefix = $null if ($dest -match '/') { $parts = $dest.Split('/') $destAddr = $parts[0] [int]$tmp = 0 if ([int]::TryParse($parts[1], [ref]$tmp)) { $prefix = $tmp } } $rows.Add([pscustomobject]@{ Destination = $destAddr PrefixLength = $prefix NextHop = [string]$r.NextHop InterfaceAlias = [string]$r.InterfaceAlias Metric = ($(if ($null -ne $r.RouteMetric) { [int]$r.RouteMetric } else { $null })) }) $count++ } catch { } } if ($routes.Count -gt $cap) { Add-Limitation -Context $Context -Module 'Network' -Message ("Route table truncated to {0} of {1} routes." -f $cap, $routes.Count) -Impact 'Some routes not captured.' | Out-Null } return ,@($rows) } catch { Write-Log -Level WARN -Message 'Get-NetRoute failed; falling back to route print.' -Module 'Network' -Exception $_ -Context $Context } } # Fallback: parse 'route print -4' (READ-ONLY). try { $res = Invoke-CommandLineSafe -FilePath 'route.exe' -Arguments @('print','-4') -TimeoutSeconds 30 if ($res -and $res.StdOut) { $lines = $res.StdOut -split "`r?`n" $inActive = $false foreach ($line in $lines) { $t = $line.Trim() if ($t -match 'Active Routes:') { $inActive = $true; continue } if ($t -match 'Persistent Routes:') { $inActive = $false; continue } if (-not $inActive) { continue } if ($t -match '^Network Destination') { continue } if ([string]::IsNullOrWhiteSpace($t)) { continue } if ($t -match '^=+$') { continue } $tokens = @($t -split '\s+' | Where-Object { $_ -ne '' }) if ($tokens.Count -lt 5) { continue } if ($tokens[0] -notmatch '^\d+\.\d+\.\d+\.\d+$') { continue } $prefix = ConvertTo-NwPrefixLength -Mask $tokens[1] $metric = $null [int]$mtmp = 0 if ([int]::TryParse($tokens[4], [ref]$mtmp)) { $metric = $mtmp } $rows.Add([pscustomobject]@{ Destination = $tokens[0] PrefixLength = $prefix NextHop = $tokens[2] InterfaceAlias = $tokens[3] Metric = $metric }) } } } catch { Write-Log -Level WARN -Message 'route print fallback failed.' -Module 'Network' -Exception $_ -Context $Context } return ,@($rows) } function Get-NwDnsRows { param([object]$Context) $rows = [System.Collections.Generic.List[object]]::new() # Suffix search list (global). try { if (Get-CommandAvailable -Name 'Get-DnsClientGlobalSetting') { $g = Get-DnsClientGlobalSetting -ErrorAction Stop $i = 0 foreach ($s in @($g.SuffixSearchList)) { if ($s) { $rows.Add([pscustomobject]@{ EntryType='SuffixSearchList'; InterfaceAlias=''; Value=[string]$s; Notes=("Order {0}" -f $i) }) $i++ } } } else { $sl = Get-RegistryValueSafe -Path 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters' -Name 'SearchList' if ($sl) { foreach ($s in ([string]$sl -split '[,; ]')) { if ($s) { $rows.Add([pscustomobject]@{ EntryType='SuffixSearchList'; InterfaceAlias=''; Value=[string]$s; Notes='registry SearchList' }) } } } } } catch { Write-Log -Level WARN -Message 'DNS suffix search list collection failed.' -Module 'Network' -Exception $_ -Context $Context } # Per-interface DNS servers. try { if (Get-CommandAvailable -Name 'Get-DnsClientServerAddress') { foreach ($d in (Get-DnsClientServerAddress -ErrorAction Stop)) { $servers = @($d.ServerAddresses | Where-Object { $_ }) if ($servers.Count -gt 0) { $rows.Add([pscustomobject]@{ EntryType = 'InterfaceDns' InterfaceAlias = [string]$d.InterfaceAlias Value = ($servers -join ', ') Notes = [string]$d.AddressFamily }) } } } else { # DnsClient module unavailable (e.g. PS7 on 2012 R2, no WinPS 5.1 compat session). # Win32_NetworkAdapterConfiguration is CIM-based and works everywhere. foreach ($nc in (Invoke-CimSafe -ClassName 'Win32_NetworkAdapterConfiguration' -Filter 'IPEnabled = True')) { $servers = @($nc.DNSServerSearchOrder | Where-Object { $_ }) if ($servers.Count -gt 0) { $rows.Add([pscustomobject]@{ EntryType = 'InterfaceDns' InterfaceAlias = [string]$nc.Description Value = ($servers -join ', ') Notes = 'Win32_NetworkAdapterConfiguration' }) } } } } catch { Write-Log -Level WARN -Message 'Per-interface DNS collection failed.' -Module 'Network' -Exception $_ -Context $Context } # Hosts file entries. try { $hostsPath = Join-Path -Path $env:SystemRoot -ChildPath 'System32\drivers\etc\hosts' if (Test-Path -LiteralPath $hostsPath) { $hostLines = Get-Content -LiteralPath $hostsPath -ErrorAction SilentlyContinue foreach ($hl in $hostLines) { $t = ([string]$hl).Trim() if ([string]::IsNullOrWhiteSpace($t)) { continue } if ($t.StartsWith('#')) { continue } $tokens = @($t -split '\s+' | Where-Object { $_ -ne '' }) if ($tokens.Count -lt 2) { continue } $ip = $tokens[0] $names = @($tokens[1..($tokens.Count - 1)]) -join ', ' $rows.Add([pscustomobject]@{ EntryType='HostsFileEntry'; InterfaceAlias=''; Value=$ip; Notes=$names }) } } } catch { Write-Log -Level WARN -Message 'Hosts file collection failed.' -Module 'Network' -Exception $_ -Context $Context } return ,@($rows) } function Get-NwListeningRows { param([object]$Context, [hashtable]$ProcMap, [hashtable]$SvcMap, [object]$Netstat) $rows = [System.Collections.Generic.List[object]]::new() if (-not $ProcMap) { $ProcMap = @{} } if (-not $SvcMap) { $SvcMap = @{} } $resolve = { param($procId) $pn = '' $sn = '' try { if ($procId -gt 0 -and $ProcMap.ContainsKey([int]$procId)) { $pn = [string]$ProcMap[[int]$procId] } } catch { } try { if ($procId -gt 0 -and $SvcMap.ContainsKey([int]$procId)) { $sn = [string]$SvcMap[[int]$procId] } } catch { } return @{ Process = $pn; Service = $sn } } if (Get-CommandAvailable -Name 'Get-NetTCPConnection') { try { foreach ($c in (Get-NetTCPConnection -State Listen -ErrorAction Stop)) { try { $procId = 0 if ($null -ne $c.OwningProcess) { $procId = [int]$c.OwningProcess } $r = & $resolve $procId # >= 49152 is the IANA ephemeral/dynamic floor. Flagged, not dropped: the raw # export stays complete and consumers (workbook filter, edge builder) decide. $portNum = 0 [void][int]::TryParse([string]$c.LocalPort, [ref]$portNum) $rows.Add([pscustomobject]@{ Protocol = 'TCP' LocalAddress = [string]$c.LocalAddress LocalPort = [string]$c.LocalPort State = 'Listen' OwningProcessId = $procId Process = $r.Process ServiceName = $r.Service IsEphemeral = ($portNum -ge 49152) }) } catch { } } } catch { Write-Log -Level WARN -Message 'Get-NetTCPConnection (Listen) failed.' -Module 'Network' -Exception $_ -Context $Context } try { if (Get-CommandAvailable -Name 'Get-NetUDPEndpoint') { foreach ($u in (Get-NetUDPEndpoint -ErrorAction Stop)) { try { $procId = 0 if ($null -ne $u.OwningProcess) { $procId = [int]$u.OwningProcess } $r = & $resolve $procId $portNum = 0 [void][int]::TryParse([string]$u.LocalPort, [ref]$portNum) $rows.Add([pscustomobject]@{ Protocol = 'UDP' LocalAddress = [string]$u.LocalAddress LocalPort = [string]$u.LocalPort State = 'Listen' OwningProcessId = $procId Process = $r.Process ServiceName = $r.Service IsEphemeral = ($portNum -ge 49152) }) } catch { } } } } catch { Write-Log -Level WARN -Message 'Get-NetUDPEndpoint failed.' -Module 'Network' -Exception $_ -Context $Context } return ,@($rows) } # Fallback: netstat rows. try { foreach ($n in @($Netstat)) { $isListening = ($n.Protocol -eq 'TCP' -and $n.State -match 'LISTEN') -or ($n.Protocol -eq 'UDP') if (-not $isListening) { continue } $procId = 0 try { $procId = [int]$n.OwningProcessId } catch { } $r = & $resolve $procId $portNum = 0 [void][int]::TryParse([string]$n.LocalPort, [ref]$portNum) $rows.Add([pscustomobject]@{ Protocol = [string]$n.Protocol LocalAddress = [string]$n.LocalAddress LocalPort = [string]$n.LocalPort State = 'Listen' OwningProcessId = $procId Process = $r.Process ServiceName = $r.Service IsEphemeral = ($portNum -ge 49152) }) } } catch { Write-Log -Level WARN -Message 'netstat listening fallback failed.' -Module 'Network' -Exception $_ -Context $Context } return ,@($rows) } function Get-NwEstablishedRows { param([object]$Context, [hashtable]$ProcMap, [object]$Netstat) $rows = [System.Collections.Generic.List[object]]::new() if (-not $ProcMap) { $ProcMap = @{} } $cap = 300 if (Get-CommandAvailable -Name 'Get-NetTCPConnection') { try { $conns = @(Get-NetTCPConnection -State Established -ErrorAction Stop) $count = 0 foreach ($c in $conns) { if ($count -ge $cap) { break } try { $procId = 0 if ($null -ne $c.OwningProcess) { $procId = [int]$c.OwningProcess } $pn = '' if ($procId -gt 0 -and $ProcMap.ContainsKey($procId)) { $pn = [string]$ProcMap[$procId] } $rows.Add([pscustomobject]@{ LocalAddress = [string]$c.LocalAddress LocalPort = [string]$c.LocalPort RemoteAddress = [string]$c.RemoteAddress RemotePort = [string]$c.RemotePort OwningProcessId = $procId Process = $pn }) $count++ } catch { } } if ($conns.Count -gt $cap) { Add-Limitation -Context $Context -Module 'Network' -Message ("Established connections truncated to {0} of {1}." -f $cap, $conns.Count) -Impact 'Some active connections not captured.' | Out-Null } return ,@($rows) } catch { Write-Log -Level WARN -Message 'Get-NetTCPConnection (Established) failed.' -Module 'Network' -Exception $_ -Context $Context } } # Fallback: netstat rows. try { $count = 0 foreach ($n in @($Netstat)) { if ($count -ge $cap) { break } if ($n.Protocol -ne 'TCP' -or $n.State -notmatch 'ESTABLISHED') { continue } $procId = 0 try { $procId = [int]$n.OwningProcessId } catch { } $pn = '' if ($procId -gt 0 -and $ProcMap.ContainsKey($procId)) { $pn = [string]$ProcMap[$procId] } $rows.Add([pscustomobject]@{ LocalAddress = [string]$n.LocalAddress LocalPort = [string]$n.LocalPort RemoteAddress = [string]$n.RemoteAddress RemotePort = [string]$n.RemotePort OwningProcessId = $procId Process = $pn }) $count++ } } catch { Write-Log -Level WARN -Message 'netstat established fallback failed.' -Module 'Network' -Exception $_ -Context $Context } return ,@($rows) } function Get-NwFirewallRows { param([object]$Context) $rows = [System.Collections.Generic.List[object]]::new() if (-not (Get-CommandAvailable -Name 'Get-NetFirewallRule')) { Add-Limitation -Context $Context -Module 'Network' -Message 'Get-NetFirewallRule not available; firewall rules not enumerated.' -Reason 'Cmdlet/service unavailable.' | Out-Null return ,@($rows) } # Safety cap only - not a performance workaround. Enrichment below is two bulk cmdlet calls # regardless of rule count (see next comment), so there is no per-rule cost to cap against; # this just bounds memory/output size on a host with an implausibly large rule store. $maxRules = 20000 try { if ($Context.Parameters -and $Context.Parameters.ContainsKey('MaxFirewallRules') -and $Context.Parameters['MaxFirewallRules']) { $maxRules = [int]$Context.Parameters['MaxFirewallRules'] } } catch { } try { $all = @(Get-NetFirewallRule -ErrorAction Stop | Where-Object { ([string]$_.Enabled -eq 'True') -and ([string]$_.Action -eq 'Allow') }) $total = $all.Count $subset = @($all | Select-Object -First $maxRules) # Get-NetFirewallPortFilter/-ApplicationFilter piped one rule at a time each re-query the # whole filter store per call (~190ms/rule measured - 345 rules took 65s+, which is what # the old per-rule time budget was actually working around). Fetching every filter once # and joining by InstanceID (shared with the owning rule's InstanceID/Name) is the same # data in ~200ms total regardless of rule count, so every rule gets full enrichment. $pfMap = @{} try { foreach ($f in (Get-NetFirewallPortFilter -ErrorAction Stop)) { $pfMap[$f.InstanceID] = $f } } catch { } $afMap = @{} try { foreach ($f in (Get-NetFirewallApplicationFilter -ErrorAction Stop)) { $afMap[$f.InstanceID] = $f } } catch { } foreach ($r in $subset) { $proto = '' $localPort = '' $program = '' $pf = $pfMap[$r.InstanceID] if ($pf) { $proto = [string]$pf.Protocol; $localPort = (@($pf.LocalPort) -join ',') } $af = $afMap[$r.InstanceID] if ($af) { $program = [string]$af.Program } $rows.Add([pscustomobject]@{ Name = [string]$r.DisplayName RuleName = [string]$r.Name DisplayGroup = [string]$r.DisplayGroup Direction = [string]$r.Direction Action = [string]$r.Action Protocol = $proto LocalPort = $localPort Program = $program Profile = [string]$r.Profile Enabled = $true }) } if ($total -gt $maxRules) { Add-Limitation -Context $Context -Module 'Network' -Message ("Enabled/allow firewall rules truncated to {0} of {1}." -f $maxRules, $total) -Impact 'Some firewall rules not captured.' | Out-Null } } catch { Add-Limitation -Context $Context -Module 'Network' -Message 'Firewall rule enumeration failed.' -Reason ([string]$_.Exception.Message) | Out-Null Write-Log -Level WARN -Message 'Get-NetFirewallRule enumeration failed.' -Module 'Network' -Exception $_ -Context $Context } return ,@($rows) } #endregion #region Six-function contract ------------------------------------------------- function Invoke-DiscoveryCollection { param([object]$Context) Write-SectionStatus -Title 'Network' -Status 'Collecting' -Context $Context $procMap = @{} $svcMap = @{} try { $procMap = Get-NwProcessMap } catch { } try { $svcMap = Get-NwServiceMap -Context $Context } catch { } $netstat = @() if (-not (Get-CommandAvailable -Name 'Get-NetTCPConnection')) { # Get-NwNetstatRows already returns a clean array via ',@(...)' - wrapping it again in # @() here double-wraps into a 1-element array containing the real array as its only # element, so every consumer's foreach ran once over the whole array via member # enumeration instead of once per row (silently near-empty ListeningPorts/ # EstablishedConnections wherever this fallback is actually exercised). try { $netstat = Get-NwNetstatRows -Context $Context } catch { $netstat = @() } } $raw = @{ NetworkAdapters = @() IPConfiguration = @() Routes = @() DnsClient = @() ListeningPorts = @() EstablishedConnections = @() FirewallRules = @() } # NOTE: helpers already return clean arrays via ',@(...)'. Do NOT wrap the call # in @() here - that would double-wrap into a 1-element array containing the array. try { $raw.NetworkAdapters = Get-NwAdapterRows -Context $Context } catch { Write-Log -Level WARN -Message 'NetworkAdapters collection failed.' -Module 'Network' -Exception $_ -Context $Context } try { $raw.IPConfiguration = Get-NwIpConfigRows -Context $Context } catch { Write-Log -Level WARN -Message 'IPConfiguration collection failed.' -Module 'Network' -Exception $_ -Context $Context } try { $raw.Routes = Get-NwRouteRows -Context $Context } catch { Write-Log -Level WARN -Message 'Routes collection failed.' -Module 'Network' -Exception $_ -Context $Context } try { $raw.DnsClient = Get-NwDnsRows -Context $Context } catch { Write-Log -Level WARN -Message 'DnsClient collection failed.' -Module 'Network' -Exception $_ -Context $Context } try { $raw.ListeningPorts = Get-NwListeningRows -Context $Context -ProcMap $procMap -SvcMap $svcMap -Netstat $netstat } catch { Write-Log -Level WARN -Message 'ListeningPorts collection failed.' -Module 'Network' -Exception $_ -Context $Context } try { $raw.EstablishedConnections = Get-NwEstablishedRows -Context $Context -ProcMap $procMap -Netstat $netstat } catch { Write-Log -Level WARN -Message 'EstablishedConnections collection failed.' -Module 'Network' -Exception $_ -Context $Context } try { $raw.FirewallRules = Get-NwFirewallRows -Context $Context } catch { Write-Log -Level WARN -Message 'FirewallRules collection failed.' -Module 'Network' -Exception $_ -Context $Context } return $raw } function ConvertTo-DiscoveryDatasets { param([object]$Context, $RawData) if ($null -eq $RawData) { Add-Limitation -Context $Context -Module 'Network' -Message 'No raw network data was returned by collection.' -Reason 'Collection returned null.' | Out-Null $RawData = @{} } $get = { param($key) if ($RawData -is [hashtable] -and $RawData.ContainsKey($key) -and $RawData[$key]) { @($RawData[$key]) } else { @() } } Add-DataSet -Context $Context -Name 'NetworkAdapters' -Description 'Physical/virtual network adapters and link status.' -Rows (& $get 'NetworkAdapters') -Visibility 'Both' -SourceModule 'Network' | Out-Null Add-DataSet -Context $Context -Name 'IPConfiguration' -Description 'Per-interface IP configuration (addresses, gateway, DNS, static/DHCP).' -Rows (& $get 'IPConfiguration') -Visibility 'Both' -SourceModule 'Network' | Out-Null Add-DataSet -Context $Context -Name 'Routes' -Description 'IP routing table entries.' -Rows (& $get 'Routes') -Visibility 'Internal' -SourceModule 'Network' | Out-Null Add-DataSet -Context $Context -Name 'DnsClient' -Description 'DNS suffix search list, per-interface DNS servers, and hosts file entries.' -Rows (& $get 'DnsClient') -Visibility 'Both' -SourceModule 'Network' | Out-Null # Ephemeral UDP sockets (a DNS server's query-response pool) can outnumber real listeners # 50:1 (5008 of 5145 rows measured on a lab DC) and are never scoping evidence. CSV/JSON keep # every row; the workbook worksheet drops non-TCP ephemerals so it doesn't bury the ~105 # actionable listeners under thousands of transient sockets. A TCP listener on a high port is # still real (a named SQL instance's dynamic port, an app on a random port), so only UDP is cut. $listeningPorts = & $get 'ListeningPorts' $listeningPortsForWorkbook = @($listeningPorts | Where-Object { -not ($_.IsEphemeral -and $_.Protocol -ne 'TCP') }) Add-DataSet -Context $Context -Name 'ListeningPorts' -Description 'Listening TCP/UDP endpoints correlated to processes/services.' -Rows $listeningPorts -WorkbookRows $listeningPortsForWorkbook -Visibility 'Internal' -SourceModule 'Network' | Out-Null Add-DataSet -Context $Context -Name 'EstablishedConnections' -Description 'Established outbound/inbound TCP connections (sampled).' -Rows (& $get 'EstablishedConnections') -Visibility 'Internal' -SourceModule 'Network' | Out-Null Add-DataSet -Context $Context -Name 'FirewallRules' -Description 'Enabled allow firewall rules (inbound/outbound) summary.' -Rows (& $get 'FirewallRules') -Visibility 'Internal' -SourceModule 'Network' | Out-Null } function Invoke-DiscoveryRiskAnalysis { param([object]$Context) # Server -> DNS servers and Server -> gateways dependency edges. try { if ($Context.DataSets.Contains('IPConfiguration')) { $dnsSeen = @{} $gwSeen = @{} foreach ($row in @($Context.DataSets['IPConfiguration'].Rows)) { try { if ($row.DnsServers) { foreach ($d in ([string]$row.DnsServers -split ',')) { $dv = $d.Trim() if ($dv -and -not $dnsSeen.ContainsKey($dv)) { $dnsSeen[$dv] = $true Add-DependencyEdge -Context $Context -SourceType 'Server' -SourceName $Context.ComputerName -DependencyType 'DNS' -Target $dv ` -Evidence ("Configured DNS server on interface '{0}'." -f $row.InterfaceAlias) -Confidence 'Confirmed' -SourceDataset 'IPConfiguration' ` -ProjectImpact 'Name resolution dependency; relevant to cutover/decommission.' ` -ValidationQuestion 'Is this DNS server being retained or migrated?' | Out-Null } } } if ($row.DefaultGateway) { foreach ($g in ([string]$row.DefaultGateway -split ',')) { $gv = $g.Trim() if ($gv -and -not $gwSeen.ContainsKey($gv)) { $gwSeen[$gv] = $true Add-DependencyEdge -Context $Context -SourceType 'Server' -SourceName $Context.ComputerName -DependencyType 'Gateway' -Target $gv ` -Evidence ("Default gateway on interface '{0}'." -f $row.InterfaceAlias) -Confidence 'Confirmed' -SourceDataset 'IPConfiguration' ` -ProjectImpact 'Upstream routing dependency.' | Out-Null } } } } catch { } } } } catch { Write-Log -Level WARN -Message 'DNS/gateway dependency edge analysis failed.' -Module 'Network' -Exception $_ -Context $Context } # Service/Process -> listening port dependency edges (deduped, capped). try { if ($Context.DataSets.Contains('ListeningPorts')) { $seen = @{} $edgeCount = 0 $edgeCap = 150 foreach ($row in @($Context.DataSets['ListeningPorts'].Rows)) { if ($edgeCount -ge $edgeCap) { break } # Ephemeral sockets (dns on a DC emitted 5008 of 5145 rows) are not listening # services; without this filter they consume the 150-edge cap and crowd out # the actionable listeners. Rows lacking the flag read as $null = keep. # Only non-TCP ephemerals are noise: a TCP listener on a high port is a real service # (a named SQL instance's dynamic port, an app on a random port) clients connect to. if ($row.IsEphemeral -and $row.Protocol -ne 'TCP') { continue } try { $srcName = '' $srcType = 'Process' if ($row.ServiceName) { $srcName = [string]$row.ServiceName; $srcType = 'Service' } elseif ($row.Process) { $srcName = [string]$row.Process; $srcType = 'Process' } if (-not $srcName) { continue } $portKey = ("{0}/{1}/{2}" -f $srcName, $row.Protocol, $row.LocalPort) if ($seen.ContainsKey($portKey)) { continue } $seen[$portKey] = $true Add-DependencyEdge -Context $Context -SourceType $srcType -SourceName $srcName -DependencyType 'ListeningPort' ` -Target ("{0}/{1}" -f $row.Protocol, $row.LocalPort) ` -Evidence ("Listening on {0} port {1} ({2})." -f $row.Protocol, $row.LocalPort, $row.LocalAddress) -Confidence 'Likely' -SourceDataset 'ListeningPorts' ` -ProjectImpact 'Other systems may connect to this port; relevant to migration/decommission.' | Out-Null $edgeCount++ } catch { } } } } catch { Write-Log -Level WARN -Message 'Listening-port dependency edge analysis failed.' -Module 'Network' -Exception $_ -Context $Context } } function Get-DiscoveryFollowUpQuestions { param([object]$Context) try { $hasStatic = $false if ($Context.DataSets.Contains('IPConfiguration')) { foreach ($row in @($Context.DataSets['IPConfiguration'].Rows)) { if ($row.IsStatic) { $hasStatic = $true; break } } } if ($hasStatic) { Add-FollowUpQuestion -Context $Context -Question 'This server uses one or more static IP addresses - are these referenced by other systems, firewall rules, or DNS records that must be updated during a migration?' -Category 'Network' -Module 'Network' -Audience 'Both' | Out-Null } } catch { } try { $listenCount = 0 if ($Context.DataSets.Contains('ListeningPorts')) { $listenCount = @($Context.DataSets['ListeningPorts'].Rows).Count } if ($listenCount -gt 0) { Add-FollowUpQuestion -Context $Context -Question 'Which client systems or applications connect to the services listening on this server? Confirm consumers before any cutover or decommission.' -Category 'Network' -Module 'Network' -Audience 'Both' | Out-Null } } catch { } try { Add-FollowUpQuestion -Context $Context -Question 'Are there any hardcoded IP addresses or server names (in application configs, firewall/DNS records, or third-party integrations) that point at this server?' -Category 'Network' -Module 'Network' -Audience 'Internal' | Out-Null } catch { } } #endregion Export-ModuleMember -Function ` 'Get-DiscoveryModuleMetadata','Test-DiscoveryPrerequisites','Invoke-DiscoveryCollection', ` 'ConvertTo-DiscoveryDatasets','Invoke-DiscoveryRiskAnalysis','Get-DiscoveryFollowUpQuestions', ` 'Get-NwProcessMap','Get-NwServiceMap','Split-NwEndpoint','ConvertTo-NwPrefixLength','Get-NwNetstatRows', ` 'Get-NwAdapterRows','Get-NwIpConfigRows','Get-NwRouteRows','Get-NwDnsRows', ` 'Get-NwListeningRows','Get-NwEstablishedRows','Get-NwFirewallRows' |