From 2c51d42ab88590d03ed412d2dbea7bb6b0d04e29 Mon Sep 17 00:00:00 2001 From: Rich Kreider Date: Tue, 19 May 2026 20:55:53 -0400 Subject: initial commit of lanscan utility --- lanscan | 571 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 571 insertions(+) create mode 100644 lanscan diff --git a/lanscan b/lanscan new file mode 100644 index 0000000..316283a --- /dev/null +++ b/lanscan @@ -0,0 +1,571 @@ +Clear-Host + +# ====== Banner ====== +Write-Host "==========================================" -ForegroundColor Cyan +Write-Host " LANScan v1.6 - Network Tool " -ForegroundColor Yellow +Write-Host " by Rich Kreider (c) 2025 " -ForegroundColor Cyan +Write-Host "==========================================" -ForegroundColor Cyan + +# ====== Configuration Defaults ====== +$Timeout = 200 +$MaxThreads = 200 +$NetBIOSThreads = 50 +$DefaultPortRange = "1-1024" +$PortList = @() +$ShowClosed = $false +$doPortScan = $false +$doNetBIOS = $false +$doCSV = $false +$csvPath = $null +$esc = [char]27 +$colorMagenta = "${esc}[35m" +$colorCyan = "${esc}[96m" +$colorYellow = "${esc}[33m" +$colorReset = "${esc}[0m" + +$OuiCachePath = Join-Path $env:TEMP "oui.csv" +$OuiCacheAgeDays = 7 +$OuiUrl = "https://standards-oui.ieee.org/oui/oui.csv" + +# ====== Helper Functions ====== +function Expand-PortRange { + param ([string]$RangeString) + $expanded = @() + $parts = $RangeString -split ',' | ForEach-Object { $_.Trim() } + foreach ($part in $parts) { + if ($part -match '^(\d+)-(\d+)$') { + $expanded += [int]$matches[1]..[int]$matches[2] + } elseif ($part -match '^\d+$') { + $expanded += [int]$part + } + } + return $expanded | Sort-Object -Unique +} + +function Show-ScanSettings { + Write-Host "`nDefault scan settings:" + Write-Host " Timeout: $Timeout ms" + Write-Host " Threads: $MaxThreads" + Write-Host " Port Range: $DefaultPortRange" +} + +function Prompt-ScanSettings { + $modify = Read-Host "`nModify scan settings? (y/N)" + if ($modify -match '^(y|yes)$') { + $newTimeout = Read-Host "Timeout in ms (current: $Timeout)" + if ($newTimeout -match '^\d+$') { $global:Timeout = [int]$newTimeout } + + $newThreads = Read-Host "Max threads (current: $MaxThreads)" + if ($newThreads -match '^\d+$') { $global:MaxThreads = [int]$newThreads } + + $newRange = Read-Host "Port list (e.g., 22,80,1000-1100)" + if ($newRange -match '^[\d,\- ]+$') { + $global:PortList = Expand-PortRange $newRange + } else { + Write-Host "Invalid format. Using default range." -ForegroundColor Yellow + $global:PortList = Expand-PortRange $DefaultPortRange + } + } else { + $global:PortList = Expand-PortRange $DefaultPortRange + } +} + +function ConvertTo-CIDR { + param ($netmask) + if ($netmask -match '^\d+$') { return [int]$netmask } + $binary = ($netmask -split '\.') | ForEach-Object { + [Convert]::ToString([int]$_, 2).PadLeft(8, '0') + } + return ($binary -join '').ToCharArray() | Where-Object { $_ -eq '1' } | Measure-Object | Select-Object -ExpandProperty Count +} + +function Get-HostRangeFromCIDR { + param ($ip, $cidr) + $ipAddr = [System.Net.IPAddress]::Parse($ip) + $ipBytes = $ipAddr.GetAddressBytes() + [Array]::Reverse($ipBytes) + $ipInt = [BitConverter]::ToUInt32($ipBytes, 0) + + $mask = ([math]::Pow(2, 32) - [math]::Pow(2, (32 - $cidr))) + $network = $ipInt -band [uint32]$mask + $broadcast = $network + ([uint32]([math]::Pow(2, 32 - $cidr)) - 1) + + $ips = @() + for ($i = $network + 1; $i -lt $broadcast; $i++) { + $bytes = [BitConverter]::GetBytes($i) + [Array]::Reverse($bytes) + $ips += ([System.Net.IPAddress]::Parse(($bytes -join '.'))).ToString() + } + return $ips +} + +function Sort-IPAddresses { + param ([string[]]$IPs) + return $IPs | Sort-Object { + $octets = $_ -split '\.' + [int]$octets[0] * 16777216 + + [int]$octets[1] * 65536 + + [int]$octets[2] * 256 + + [int]$octets[3] + } +} + +# Returns array of open port numbers +function Start-PortScan { + param ([string]$TargetIP) + + try { + $resolvedIP = [System.Net.Dns]::GetHostAddresses($TargetIP) | + Where-Object { $_.AddressFamily -eq 'InterNetwork' } | + Select-Object -First 1 + if (-not $resolvedIP) { throw "No IPv4 address found." } + } catch { + Write-Error "DNS resolution failed for ${TargetIP}: $_" + return @() + } + + $runspacePool = [runspacefactory]::CreateRunspacePool(1, $MaxThreads) + $runspacePool.Open() + $runspaces = @() + + foreach ($port in $PortList) { + $ps = [powershell]::Create().AddScript({ + param($ip, $port, $timeout) + try { + $endpoint = New-Object System.Net.IPEndPoint($ip, $port) + $socket = New-Object System.Net.Sockets.Socket( + [System.Net.Sockets.AddressFamily]::InterNetwork, + [System.Net.Sockets.SocketType]::Stream, + [System.Net.Sockets.ProtocolType]::Tcp + ) + $iar = $socket.BeginConnect($endpoint, $null, $null) + $connected = $iar.AsyncWaitHandle.WaitOne($timeout, $false) + if ($connected -and $socket.Connected) { + $socket.EndConnect($iar); $socket.Close() + return [PSCustomObject]@{ Port = $port; Open = $true } + } else { + $socket.Close() + return [PSCustomObject]@{ Port = $port; Open = $false } + } + } catch { + return [PSCustomObject]@{ Port = $port; Open = $false } + } + }).AddArgument($resolvedIP).AddArgument($port).AddArgument($Timeout) + + $ps.RunspacePool = $runspacePool + $runspaces += [PSCustomObject]@{ Pipe = $ps; Async = $ps.BeginInvoke() } + } + + $results = @() + foreach ($r in $runspaces) { + $result = $r.Pipe.EndInvoke($r.Async) + $r.Pipe.Dispose() + $results += $result + } + + $runspacePool.Close() + $runspacePool.Dispose() + + if ($ShowClosed) { + foreach ($r in ($results | Where-Object { -not $_.Open })) { + Write-Host " -> Port $($r.Port) is CLOSED on $TargetIP" + } + } + + return @($results | Where-Object { $_.Open } | Select-Object -ExpandProperty Port) +} + +# Cascading hostname resolution: DNS -> nbtstat -> WMI/CIM +function Start-HostnameResolution { + param ([string[]]$IPs) + + $nbPool = [runspacefactory]::CreateRunspacePool(1, $NetBIOSThreads) + $nbPool.Open() + $nbRunspaces = @() + + $resolveScript = { + param([string]$IP) + + $result = [PSCustomObject]@{ + IP = $IP + Hostname = $null + NBName = $null + NBWorkgroup = $null + Method = $null + } + + try { + $entry = [System.Net.Dns]::GetHostEntry($IP) + if ($entry.HostName -and $entry.HostName -ne $IP) { + $result.Hostname = $entry.HostName + $result.Method = 'DNS' + return $result + } + } catch {} + + try { + $nbt = & nbtstat.exe -A $IP 2>$null + if ($nbt) { + $nameLine = $nbt | Where-Object { $_ -match '<00>\s+UNIQUE' } | Select-Object -First 1 + if ($nameLine -match '^\s*(\S+)\s+<00>') { + $result.NBName = $matches[1].Trim() + $result.Hostname = $result.NBName + $result.Method = 'NetBIOS' + } + $wgLine = $nbt | Where-Object { $_ -match '<00>\s+GROUP' } | Select-Object -First 1 + if ($wgLine -match '^\s*(\S+)\s+<00>') { + $result.NBWorkgroup = $matches[1].Trim() + } + if ($result.Hostname) { return $result } + } + } catch {} + + try { + $cimOpts = New-CimSessionOption -Protocol Dcom + $session = New-CimSession -ComputerName $IP -SessionOption $cimOpts ` + -OperationTimeoutSec 3 -ErrorAction Stop + $cim = Get-CimInstance -CimSession $session -ClassName Win32_ComputerSystem ` + -ErrorAction Stop + Remove-CimSession $session -ErrorAction SilentlyContinue + if ($cim.DNSHostName) { + $result.Hostname = $cim.DNSHostName + $result.Method = 'WMI' + $result.NBWorkgroup = if ($cim.PartOfDomain) { $cim.Domain } else { $cim.Workgroup } + } + } catch {} + + return $result + } + + foreach ($ip in $IPs) { + $ps = [powershell]::Create().AddScript($resolveScript).AddArgument($ip) + $ps.RunspacePool = $nbPool + $nbRunspaces += [PSCustomObject]@{ Pipe = $ps; Async = $ps.BeginInvoke(); IP = $ip } + } + + $hostnameMap = @{} + $nbTotal = $nbRunspaces.Count + $nbCount = 0 + + foreach ($r in $nbRunspaces) { + try { + $res = $r.Pipe.EndInvoke($r.Async) + if ($res) { $hostnameMap[$r.IP] = $res } + } catch {} + finally { $r.Pipe.Dispose() } + $nbCount++ + Write-Progress -Activity "Resolving Hostnames" ` + -Status "$nbCount of $nbTotal" ` + -PercentComplete ([int](($nbCount / $nbTotal) * 100)) + } + + $nbPool.Close() + $nbPool.Dispose() + Write-Progress -Activity "Resolving Hostnames" -Completed + return $hostnameMap +} + +# Downloads OUI CSV from IEEE if missing or stale, returns hashtable keyed by normalized OUI prefix +function Get-OuiTable { + $download = $true + + if (Test-Path $OuiCachePath) { + $age = (Get-Date) - (Get-Item $OuiCachePath).LastWriteTime + if ($age.TotalDays -lt $OuiCacheAgeDays) { + Write-Host "Using cached OUI database ($([math]::Floor($age.TotalDays))d old)." -ForegroundColor Cyan + $download = $false + } else { + Write-Host "OUI cache is $([math]::Floor($age.TotalDays))d old — refreshing..." -ForegroundColor Yellow + } + } else { + Write-Host "No OUI cache found — downloading..." -ForegroundColor Yellow + } + + if ($download) { + try { + Invoke-WebRequest -Uri $OuiUrl -OutFile $OuiCachePath -UseBasicParsing -UserAgent "LANscan" + Write-Host "OUI database cached to $OuiCachePath" -ForegroundColor Cyan + } catch { + Write-Warning "Failed to download OUI database: $_" + # If a stale cache exists, fall back to it rather than failing completely + if (-not (Test-Path $OuiCachePath)) { return @{} } + Write-Host "Falling back to stale OUI cache." -ForegroundColor Yellow + } + } + + $ouiTable = @{} + try { + Import-Csv $OuiCachePath | ForEach-Object { + # IEEE CSV columns: Registry,Assignment,Organization Name,Organization Address + $key = $_.Assignment.ToUpper() -replace '[^0-9A-F]', '' + if ($key.Length -eq 6 -and -not $ouiTable.ContainsKey($key)) { + $ouiTable[$key] = $_.'Organization Name'.Trim() + } + } + Write-Host "$($ouiTable.Count) OUI entries loaded." -ForegroundColor Cyan + } catch { + Write-Warning "Failed to parse OUI database: $_" + } + + return $ouiTable +} + +function Get-Vendor { + param ([string]$MAC, [hashtable]$OuiTable) + # Normalize MAC to 6 uppercase hex chars (first 3 octets) + $normalized = $MAC.ToUpper() -replace '[^0-9A-F]', '' + if ($normalized.Length -lt 6) { return '' } + $oui = $normalized.Substring(0, 6) + if ($OuiTable.ContainsKey($oui)) { return $OuiTable[$oui] } + return '' +} + +# ====== Initial Prompts ====== +$portInput = Read-Host "`nRun port scan on discovered hosts? (y/N)" +if ($portInput -match '^(y|yes)$') { + $doPortScan = $true + Show-ScanSettings + Prompt-ScanSettings +} + +$nbInput = Read-Host "Run NetBIOS/hostname lookups on discovered hosts? (y/N)" +if ($nbInput -match '^(y|yes)$') { $doNetBIOS = $true } + +$csvInput = Read-Host "Save results to CSV? (y/N)" +if ($csvInput -match '^(y|yes)$') { + $doCSV = $true + $csvPath = Join-Path $PWD "lanscan_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" + Write-Host " -> Output: $csvPath" -ForegroundColor Cyan +} + +# ====== OUI Database ====== +Write-Host "`nLoading OUI vendor database..." -ForegroundColor Green +$ouiTable = Get-OuiTable + +# ====== Network Discovery ====== +Write-Host "`nStarting network discovery..." -ForegroundColor Green + +$ipsToPing = @() +$manualMode = $false +$manualEntry = Read-Host "Enter a manual IP and CIDR/netmask? (y/N)" + +if ($manualEntry -match '^(y|yes)$') { + $manualMode = $true + $ipInput = Read-Host "Enter IP address (e.g., 192.168.1.0)" + $maskInput = Read-Host "Enter CIDR or netmask (e.g., 24 or 255.255.255.0)" + $cidr = ConvertTo-CIDR $maskInput + if (-not $cidr -or $cidr -lt 0 -or $cidr -gt 32) { + Write-Host "Invalid subnet mask or CIDR." -ForegroundColor Red + exit + } + $ipsToPing = Get-HostRangeFromCIDR $ipInput $cidr +} else { + $adapters = Get-NetIPAddress -AddressFamily IPv4 | + Where-Object { $_.PrefixOrigin -in ('Dhcp', 'Manual') -and $_.InterfaceAlias -notmatch 'Loopback' } + + if (-not $adapters) { + Write-Host "No active network adapters found." -ForegroundColor Red + exit + } + + foreach ($adapter in $adapters) { + $baseIP = $adapter.IPAddress -replace '\.\d+$', '.' + $ipsToPing += (1..254 | ForEach-Object { "$baseIP$_" }) + } +} + +# ====== Ping Sweep ====== +$pingPool = [runspacefactory]::CreateRunspacePool(1, $MaxThreads) +$pingPool.Open() +$pingRunspaces = @() + +foreach ($ip in $ipsToPing) { + $pingJob = [powershell]::Create().AddScript({ + param($ip, $timeout) + $ping = New-Object System.Net.NetworkInformation.Ping + try { $null = $ping.Send($ip, $timeout) } catch {} + }).AddArgument($ip).AddArgument($Timeout) + + $pingJob.RunspacePool = $pingPool + $pingRunspaces += [PSCustomObject]@{ Pipe = $pingJob; Handle = $pingJob.BeginInvoke() } +} + +$activeIPs = @() +$totalIPs = $ipsToPing.Count +$currentCount = 0 + +foreach ($r in $pingRunspaces) { + if ($null -eq $r -or $null -eq $r.Pipe) { continue } + try { + [void]$r.Pipe.EndInvoke($r.Handle) + while ($r.Pipe.Output.Count -gt 0) { + $ipString = $r.Pipe.Output.Read() + if ($ipString) { $activeIPs += $ipString.ToString() } + } + } catch { + Write-Warning "Ping error at index ${currentCount}: $_" + } finally { + $r.Pipe.Dispose() + } + $currentCount++ + Write-Progress -Activity "Pinging Hosts" ` + -Status "IP $currentCount of ${totalIPs}: $($ipsToPing[$currentCount - 1])" ` + -PercentComplete ([int](($currentCount / $totalIPs) * 100)) +} + +$pingPool.Close() +$pingPool.Dispose() +Write-Progress -Activity "Pinging Hosts" -Completed + +# ====== ARP Table Lookup ====== +$arp = arp -a +$ipMacMap = @{} +$manualSubnetPrefix = if ($manualMode) { $ipInput -replace '\.\d+$', '.' } else { $null } + +foreach ($line in $arp) { + if ($line -match '(\d{1,3}(\.\d{1,3}){3})\s+([a-fA-F0-9:-]{17})') { + $ip = $matches[1] + $mac = $matches[3] + if ($manualMode) { + if ($ip.StartsWith($manualSubnetPrefix)) { $ipMacMap[$ip] = $mac } + } else { + $ipMacMap[$ip] = $mac + } + } +} + +foreach ($ip in $activeIPs) { + if (-not $ipMacMap.ContainsKey($ip)) { $ipMacMap[$ip] = 'N/A' } +} + +if ($ipMacMap.Count -eq 0) { + Write-Host "No hosts discovered." -ForegroundColor Yellow + exit +} + +Write-Host "$($ipMacMap.Count) host(s) discovered." -ForegroundColor Green + +# ====== Hostname Resolution ====== +$hostnameMap = @{} +if ($doNetBIOS) { + Write-Host "`nResolving hostnames (DNS -> NetBIOS -> WMI)..." -ForegroundColor Green + $hostnameMap = Start-HostnameResolution -IPs ([string[]]$ipMacMap.Keys) + $resolved = ($hostnameMap.Values | Where-Object { $_.Hostname }).Count + Write-Host "$resolved of $($ipMacMap.Count) host(s) resolved." -ForegroundColor Green +} + +# ====== Classify IP ====== +function Get-IPType { + param([string]$IP) + $first = [int]($IP -split '\.')[0] + if ($IP -eq '255.255.255.255' -or $IP -match '\.255$') { return 'broadcast' } + if ($first -ge 224 -and $first -le 239) { return 'multicast' } + return 'unicast' +} + +# ====== Build and Display Output ====== +Write-Host "`nDiscovered Hosts:" -ForegroundColor Green + +$csvRows = [System.Collections.Generic.List[PSCustomObject]]::new() +$sortedIPs = Sort-IPAddresses -IPs ([string[]]$ipMacMap.Keys) + +if ($doPortScan) { + Write-Host "Starting port scan on discovered hosts..." -ForegroundColor Green +} + +$hostTotal = $sortedIPs.Count +$hostCount = 0 +$separatorWritten = $false + +Write-Host "" +Write-Host " +------------------+-------------------+----------------------------+" -ForegroundColor Cyan +Write-Host " | IP Address | MAC Address | Vendor |" -ForegroundColor Cyan +Write-Host " +------------------+-------------------+----------------------------+" -ForegroundColor Cyan + +foreach ($ip in $sortedIPs) { + $mac = $ipMacMap[$ip] + $ipType = Get-IPType $ip + $vendor = Get-Vendor -MAC $mac -OuiTable $ouiTable + if (-not $vendor) { + if ($ipType -eq 'multicast') { $vendor = 'Multicast' } + elseif ($ipType -eq 'broadcast') { $vendor = 'Broadcast' } + else { $vendor = 'Unknown' } + } + + $hn = $hostnameMap[$ip] + $hostname = if ($hn -and $hn.Hostname) { $hn.Hostname } else { '' } + $nbName = if ($hn -and $hn.NBName) { $hn.NBName } else { '' } + $nbWorkgroup = if ($hn -and $hn.NBWorkgroup) { $hn.NBWorkgroup } else { '' } + $method = if ($hn -and $hn.Method) { $hn.Method } else { '' } + + # Port scan + $openPortNums = @() + if ($doPortScan) { + $hostCount++ + Write-Progress -Activity "Port Scanning Hosts" ` + -Status "Scanning $ip ($hostCount of $hostTotal)" ` + -PercentComplete ([int](($hostCount / $hostTotal) * 100)) + $openPortNums = Start-PortScan $ip + } + + # Separator before first multicast/broadcast block + if ($ipType -ne 'unicast' -and -not $separatorWritten) { + Write-Host " +------------------+-------------------+----------------------------+" -ForegroundColor DarkGray + $separatorWritten = $true + } + + if ($ipType -ne 'unicast') { + # Dimmed row + $hostSuffix = if ($hostname) { " $hostname" } else { '' } + Write-Host " | $ip" -ForegroundColor DarkGray -NoNewline + Write-Host " $mac $vendor$hostSuffix" -ForegroundColor DarkGray + } else { + # Normal colored row + $hostSuffix = if ($hostname) { "" } else { '' } + Write-Host " | " -ForegroundColor Cyan -NoNewline + Write-Host $ip -ForegroundColor Cyan -NoNewline + Write-Host " $mac " -NoNewline + Write-Host $vendor -ForegroundColor Yellow -NoNewline + if ($hostname) { + Write-Host " " -NoNewline + Write-Host $hostname -ForegroundColor Magenta -NoNewline + if ($nbWorkgroup) { Write-Host " ($nbWorkgroup)" -ForegroundColor DarkGray -NoNewline } + if ($method) { Write-Host " [$method]" -ForegroundColor DarkYellow -NoNewline } + } + Write-Host "" + + if ($openPortNums.Count -gt 0) { + Write-Host " | +-- ports: $($openPortNums -join ' ')" -ForegroundColor Cyan + } + } + + if ($doCSV) { + $csvRows.Add([PSCustomObject]@{ + IP = $ip + MAC = $mac + Vendor = $vendor + Hostname = $hostname + NBName = $nbName + NBWorkgroup = $nbWorkgroup + Method = $method + OpenPorts = ($openPortNums -join ';') + }) + } +} + +if ($doPortScan) { Write-Progress -Activity "Port Scanning Hosts" -Completed } + +Write-Host " +------------------+-------------------+----------------------------+" -ForegroundColor Cyan + +# ====== CSV Export ====== +if ($doCSV -and $csvRows.Count -gt 0) { + try { + $csvRows | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 + Write-Host "`nResults saved to: $csvPath" -ForegroundColor Cyan + } catch { + Write-Warning "Failed to save CSV: $_" + } +} elseif ($doCSV) { + Write-Host "`nNo rows to export." -ForegroundColor Yellow +} + -- cgit v1.2.3