<# .SYNOPSIS Infraback CLI ToolKit - IPScanner .DESCRIPTION Menu principal du ToolKit, puis decouverte asynchrone des hotes actifs d'un segment IPv4 (notation CIDR) avec interface TUI pilotee au clavier : ecran de demarrage, chargement, resultats (IP, nom, MAC, fabricant, services) et export JSON / CSV / Markdown. Navigation : fleches haut/bas, Entree pour valider, Echap pour revenir, PgUp/PgDn pour faire defiler le tableau des resultats. .PARAMETER Cidr Plage pre-remplie, ex. 192.168.1.0/24 (prefixe /16 a /32). Par defaut : reseau de la carte active. .PARAMETER TimeoutMs Delai d'attente par ping ICMP (ms). .PARAMETER MaxConcurrency Nombre maximal de pings simultanes. .PARAMETER MaxAttempts Tentatives ICMP maximum par adresse (1 a 5, defaut 3). .PARAMETER RetryDelayMs Delai (ms) avant une nouvelle tentative sur un hote muet (defaut 300). .PARAMETER NoArp Desactive la detection par la table de voisinage ARP de Windows. .PARAMETER ArpWaitMs Attente maximale (ms) pour confirmer les entrees ARP en verification (0 = aucune attente). .PARAMETER NoResolve Desactive la resolution DNS inverse (colonne NAME). .PARAMETER NoServices Desactive la detection des services (colonne SERVICES). .PARAMETER ServiceTimeoutMs Delai (ms) d'une connexion TCP lors de la detection des services. .PARAMETER OuiPath Fichier fabricants (prefixe MACnom). Par defaut : oui.txt a cote du script. .PARAMETER ExportDir Dossier des exports (par defaut : dossier courant). .EXAMPLE .\Infraback-IPScanner.ps1 -Cidr 192.168.1.0/24 #> #Requires -Version 5.1 [CmdletBinding()] param( [string]$Cidr, [ValidateRange(100, 10000)][int]$TimeoutMs = 800, [ValidateRange(1, 512)][int]$MaxConcurrency = 256, [ValidateRange(1, 5)][int]$MaxAttempts = 3, [ValidateRange(0, 5000)][int]$RetryDelayMs = 300, [switch]$NoArp, [ValidateRange(0, 10000)][int]$ArpWaitMs = 4000, [switch]$NoResolve, [switch]$NoServices, [ValidateRange(100, 5000)][int]$ServiceTimeoutMs = 400, [string]$OuiPath = (Join-Path $(if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }) 'oui.txt'), [string]$ExportDir = (Get-Location).Path ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($Host.Name -ne 'ConsoleHost') { throw "This tool needs a real console (PowerShell / Windows Terminal), not the ISE." } try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} # Caracteres speciaux generes par code : le fichier reste 100 % ASCII (aucun souci d'encodage / BOM) $RULE = [string][char]0x2014 $BLOCK = [string][char]0x2588 $SHADE = [string][char]0x2591 $ARROW = [string][char]0x2192 $UPA = [string][char]0x2191 $DNA = [string][char]0x2193 $SPIN = @([string][char]0x21BA, [string][char]0x21BB) $ESC = [string][char]27 $INV = [System.Globalization.CultureInfo]::InvariantCulture #region ---------- CIDR ---------- function ConvertTo-UInt32Value([System.Net.IPAddress]$Ip) { $b = $Ip.GetAddressBytes(); [Array]::Reverse($b) [int64][BitConverter]::ToUInt32($b, 0) } function ConvertFrom-UInt32Value([int64]$N) { $b = [BitConverter]::GetBytes([uint32]$N); [Array]::Reverse($b) ([System.Net.IPAddress]::new($b)).ToString() } function Get-CidrRange([string]$Text) { $Text = $Text.Trim() if ($Text -notmatch '^(\d{1,3}(?:\.\d{1,3}){3})/(\d{1,2})$') { throw "Invalid format: '$Text'. Expected: a.b.c.d/n (e.g. 192.168.1.0/24)." } $ip = $null if (-not [System.Net.IPAddress]::TryParse($Matches[1], [ref]$ip)) { throw "Invalid IPv4 address: '$($Matches[1])'." } $prefix = [int]$Matches[2] if ($prefix -lt 16 -or $prefix -gt 32) { throw "Prefix /$prefix is not supported (allowed: /16 to /32)." } $addr = ConvertTo-UInt32Value $ip $size = [int64]1 -shl (32 - $prefix) $net = $addr - ($addr % $size) $first = $net $last = $net + $size - 1 if ($prefix -le 30) { $first++; $last-- } # exclut reseau et broadcast [pscustomobject]@{ Cidr = "$(ConvertFrom-UInt32Value $net)/$prefix" First = $first Last = $last Count = $last - $first + 1 } } #endregion #region ---------- Interfaces reseau locales ---------- function Get-NetInfo { $list = [System.Collections.Generic.List[object]]::new() try { foreach ($nic in [System.Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces()) { if ($nic.OperationalStatus -ne 'Up' -or $nic.NetworkInterfaceType -eq 'Loopback') { continue } $props = $nic.GetIPProperties() $gws = @($props.GatewayAddresses | Where-Object { $_.Address.AddressFamily -eq 'InterNetwork' -and $_.Address.ToString() -ne '0.0.0.0' } | ForEach-Object { $_.Address.ToString() }) $mac = (($nic.GetPhysicalAddress().GetAddressBytes() | ForEach-Object { $_.ToString('X2') }) -join ':') foreach ($ua in $props.UnicastAddresses) { if ($ua.Address.AddressFamily -ne 'InterNetwork' -or $null -eq $ua.IPv4Mask) { continue } $mask = ConvertTo-UInt32Value $ua.IPv4Mask $ip = ConvertTo-UInt32Value $ua.Address $list.Add([pscustomobject]@{ Name = $nic.Name; Ip = $ua.Address.ToString(); Mask = $mask Net = ($ip -band $mask); Gateways = $gws; Mac = $mac }) } } } catch {} return $list.ToArray() } function Get-DefaultCidr { try { $nets = @(Get-NetInfo) $n = $nets | Where-Object { $_.Gateways.Count -gt 0 } | Select-Object -First 1 if (-not $n) { $n = $nets | Select-Object -First 1 } if ($n) { $p = 0 for ($i = 0; $i -lt 32; $i++) { if ((($n.Mask -shr (31 - $i)) -band 1) -eq 1) { $p++ } } if ($p -lt 16 -or $p -gt 30) { $p = 24 } return (Get-CidrRange "$($n.Ip)/$p").Cidr } } catch {} return '192.168.1.0/24' } #endregion #region ---------- Table de voisinage ARP (lecture seule, aucun envoi) ---------- # Pendant les pings, Windows emet lui-meme des requetes ARP vers chaque adresse du segment. # Un hote qui repond a l'ARP (meme s'il ignore l'ICMP) apparait ensuite 'Reachable' dans la table. $NbAvailable = (-not $NoArp) -and [bool](Get-Command Get-NetNeighbor -ErrorAction SilentlyContinue) function Get-NeighborTable { $map = @{} try { foreach ($n in (Get-NetNeighbor -AddressFamily IPv4 -ErrorAction Stop)) { $mac = ("$($n.LinkLayerAddress)" -replace '-', ':').ToUpper() if ($mac -match '^(00(:00){5}|FF(:FF){5})?$' -or $mac -match '^(01:00:5E|33:33)') { continue } $map["$($n.IPAddress)"] = @{ Mac = $mac; State = "$($n.State)" } } } catch {} return $map } #endregion #region ---------- Ports TCP testes (detection des services) ---------- # L'outil teste seulement si le port accepte une connexion TCP : il affiche les numeros, sans deviner le service. [int[]]$SvcPortList = 21, 22, 23, 53, 80, 443, 445, 548, 631, 1883, 3389, 5000, 5001, 5900, 8006, 8080, 8123, 9100, 32400 #endregion #region ---------- Fabricants (fichier OUI) ---------- $script:Oui = @{} $script:OuiOk = $false $script:OuiTried = $false function Import-Oui { if (-not (Test-Path -LiteralPath $OuiPath)) { return } try { foreach ($line in [System.IO.File]::ReadLines($OuiPath)) { $i = $line.IndexOf("`t") if ($i -lt 2 -or $line[0] -eq '#') { continue } $script:Oui[$line.Substring(0, $i).ToUpper()] = $line.Substring($i + 1).Trim() } $script:OuiOk = ($script:Oui.Count -gt 0) } catch {} } function Get-Vendor([string]$Mac) { if (-not $Mac -or -not $script:OuiOk) { return '-' } $hex = ($Mac -replace '[^0-9A-Fa-f]', '').ToUpper() if ($hex.Length -lt 6) { return '-' } # Bit 'administre localement' : adresse aleatoire (ex. adresse Wi-Fi privee iOS/Android), fabricant indeterminable if (([Convert]::ToInt32($hex.Substring(0, 2), 16) -band 2) -ne 0) { return '-' } foreach ($len in 9, 7, 6) { if ($hex.Length -ge $len) { $k = $hex.Substring(0, $len) if ($script:Oui.ContainsKey($k)) { return $script:Oui[$k] } } } return '-' } #endregion #region ---------- Etat ---------- $S = @{ Range = $null Next = [int64]0 InFlight = [System.Collections.Generic.List[object]]::new() DnsJobs = [System.Collections.Generic.List[object]]::new() Retry = [System.Collections.Generic.List[object]]::new() All = [System.Collections.Generic.List[object]]::new() Up = [System.Collections.Generic.List[object]]::new() Scanned = 0 Running = $false Started = [datetime]::Now Ended = $null ArpOn = $false NbPhase = 0 # 0 = pas commence, 1 = verification ARP, 2 = termine NbFirst = $true NbNext = [datetime]::MinValue NbDeadline = [datetime]::MinValue NbPending = [System.Collections.Generic.List[object]]::new() SvcPhase = 0 # 0 = pas commence, 1 = detection des services, 2 = termine SvcQueue = [System.Collections.Generic.List[object]]::new() SvcIdx = 0 SvcJobs = [System.Collections.Generic.List[object]]::new() Net = @() Interface = '-' Duration = 0.0 Rows = @() } $UI = @{ Screen = 'menu' # menu | start | scan | result | export Sel = 0 Edit = $false Buf = '' Target = '' Msg = '' MsgOk = $false Warn = '' Scroll = 0 PageSize = 10 } $script:Quit = $false #endregion #region ---------- Moteur de scan (non bloquant) ---------- function Start-Scan([string]$Text) { $range = Get-CidrRange $Text Stop-Scan $S.Range = $range $S.Next = $range.First $S.Scanned = 0 $S.NbPhase = 0; $S.NbFirst = $true $S.SvcPhase = 0; $S.SvcIdx = 0 $S.ArpOn = $NbAvailable $S.Net = @(Get-NetInfo) $S.Rows = @() $S.Started = [datetime]::Now; $S.Ended = $null $S.Running = $true } function Stop-Scan { $S.Running = $false foreach ($f in $S.InFlight) { try { $f.Ping.Dispose() } catch {} } foreach ($j in $S.SvcJobs) { try { $j.Client.Close() } catch {} } $S.InFlight.Clear(); $S.DnsJobs.Clear(); $S.Retry.Clear(); $S.All.Clear(); $S.Up.Clear() $S.NbPending.Clear(); $S.SvcQueue.Clear(); $S.SvcJobs.Clear() } function Update-MacAddresses { try { $arp = & arp.exe -a 2>$null $map = @{} foreach ($l in $arp) { if ($l -match '^\s*(\d{1,3}(?:\.\d{1,3}){3})\s+([0-9a-fA-F]{2}(?:[-:][0-9a-fA-F]{2}){5})') { $map[$Matches[1]] = ($Matches[2] -replace '-', ':').ToUpper() } } foreach ($h in $S.Up) { if ($map.ContainsKey($h.IP)) { $h.MAC = $map[$h.IP] } } } catch {} } function Set-HostUp($H) { $H.Status = 'Up' $S.Up.Add($H) if ($NoResolve) { $H.Hote = '-' } else { $S.DnsJobs.Add(@{ Host = $H; Task = [System.Net.Dns]::GetHostEntryAsync($H.IP); T0 = [datetime]::Now }) } } function Set-HostArp($H, [string]$Mac) { $H.Via = 'ARP' $H.MAC = $Mac Set-HostUp $H } function Start-IcmpProbe($H) { $p = [System.Net.NetworkInformation.Ping]::new() $S.InFlight.Add(@{ Host = $H; Ping = $p; Task = $p.SendPingAsync($H.IP, $TimeoutMs) }) } # Phase 'voisinage' : passage 1 = reperer les hotes muets presents dans la table ; # passages suivants = attendre (au plus ArpWaitMs) que Windows confirme ou infirme les entrees douteuses. function Update-NeighborPhase([datetime]$Now) { $tbl = Get-NeighborTable if ($S.NbFirst) { $S.NbFirst = $false foreach ($ip in @($tbl.Keys)) { $addr = $null if (-not [System.Net.IPAddress]::TryParse($ip, [ref]$addr)) { continue } $num = ConvertTo-UInt32Value $addr if ($num -lt $S.Range.First -or $num -gt $S.Range.Last) { continue } $h = $S.All[[int]($num - $S.Range.First)] if ($h.Status -ne 'Down') { continue } $e = $tbl[$ip] if ($e.State -eq 'Reachable') { Set-HostArp $h $e.Mac } elseif ($ArpWaitMs -gt 0 -and $e.State -in 'Stale', 'Delay', 'Probe') { $h.Status = 'ARP?' $S.NbPending.Add($h) } } } else { for ($i = $S.NbPending.Count - 1; $i -ge 0; $i--) { $h = $S.NbPending[$i] $e = if ($tbl.ContainsKey($h.IP)) { $tbl[$h.IP] } else { $null } if ($null -ne $e -and $e.State -eq 'Reachable') { Set-HostArp $h $e.Mac; $S.NbPending.RemoveAt($i) } elseif ($null -eq $e -or $e.State -in 'Unreachable', 'Incomplete') { $h.Status = 'Down'; $S.NbPending.RemoveAt($i) } } } if ($S.NbPending.Count -eq 0 -or $Now -ge $S.NbDeadline) { foreach ($h in $S.NbPending) { $h.Status = 'Down' } $S.NbPending.Clear() $S.NbPhase = 2 } else { $S.NbNext = $Now.AddMilliseconds(400) } } function Invoke-ScanStep { if (-not $S.Running) { return } $now = [datetime]::Now # 1) Recolte des pings termines for ($i = $S.InFlight.Count - 1; $i -ge 0; $i--) { $f = $S.InFlight[$i] if (-not $f.Task.IsCompleted) { continue } $S.InFlight.RemoveAt($i) $up = $false if (-not $f.Task.IsFaulted -and -not $f.Task.IsCanceled) { $r = $f.Task.Result if ($r.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) { $up = $true; $f.Host.RTT = [int]$r.RoundtripTime } } try { $f.Ping.Dispose() } catch {} $h = $f.Host if ($up) { $S.Scanned++ Set-HostUp $h } elseif ($h.Tries -lt $MaxAttempts) { # Pas de reponse : nouvelle tentative apres un court delai (appareils en veille) $h.Tries++ $h.Status = "Retry $($h.Tries)" $S.Retry.Add(@{ Host = $h; At = $now.AddMilliseconds($RetryDelayMs) }) } else { $S.Scanned++ $h.Status = 'Down' } } # 2) Nouvelles tentatives arrivees a echeance, puis nouveaux hotes (dans la limite de concurrence) for ($i = $S.Retry.Count - 1; $i -ge 0 -and $S.InFlight.Count -lt $MaxConcurrency; $i--) { if ($S.Retry[$i].At -le $now) { Start-IcmpProbe $S.Retry[$i].Host $S.Retry.RemoveAt($i) } } $launched = 0 while ($S.InFlight.Count -lt $MaxConcurrency -and $S.Next -le $S.Range.Last -and $launched -lt 64) { $ipText = ConvertFrom-UInt32Value $S.Next $h = [pscustomobject]@{ IP = $ipText; Num = $S.Next; Status = '...'; RTT = $null; Tries = 1; Via = 'ICMP' Hote = '...'; MAC = ''; Ports = [System.Collections.Generic.List[int]]::new() } $S.All.Add($h) Start-IcmpProbe $h $S.Next++; $launched++ } # 3) Resolutions DNS en cours for ($i = $S.DnsJobs.Count - 1; $i -ge 0; $i--) { $j = $S.DnsJobs[$i] $timedOut = ($now - $j.T0).TotalSeconds -gt 3 if ($j.Task.IsCompleted -or $timedOut) { $name = '-' if ($j.Task.IsCompleted -and -not $j.Task.IsFaulted -and -not $j.Task.IsCanceled) { $name = $j.Task.Result.HostName if ($name -match '^\d{1,3}(\.\d{1,3}){3}$') { $name = '-' } # pas de PTR : le resolveur renvoie l'IP } $j.Host.Hote = $name $S.DnsJobs.RemoveAt($i) } } # 4) Phase ICMP terminee -> verification de la table de voisinage ARP $icmpDone = ($S.Next -gt $S.Range.Last -and $S.InFlight.Count -eq 0 -and $S.Retry.Count -eq 0) if ($icmpDone -and $S.NbPhase -eq 0) { if ($S.ArpOn) { $S.NbPhase = 1 $S.NbDeadline = $now.AddMilliseconds($ArpWaitMs) $S.NbNext = [datetime]::MinValue } else { $S.NbPhase = 2 } } if ($S.NbPhase -eq 1 -and $now -ge $S.NbNext) { Update-NeighborPhase $now } # 5) Detection des services (connexions TCP asynchrones sur les hotes actifs) if ($S.NbPhase -eq 2 -and $S.SvcPhase -eq 0) { if ($NoServices -or $S.Up.Count -eq 0) { $S.SvcPhase = 2 } else { foreach ($h in $S.Up) { foreach ($p in $SvcPortList) { $S.SvcQueue.Add(@{ Host = $h; Port = $p }) } } $S.SvcIdx = 0 $S.SvcPhase = 1 } } if ($S.SvcPhase -eq 1) { while ($S.SvcIdx -lt $S.SvcQueue.Count -and $S.SvcJobs.Count -lt 256) { $q = $S.SvcQueue[$S.SvcIdx]; $S.SvcIdx++ $c = [System.Net.Sockets.TcpClient]::new() $S.SvcJobs.Add(@{ Host = $q.Host; Port = [int]$q.Port; Client = $c; Task = $c.ConnectAsync($q.Host.IP, [int]$q.Port); T0 = $now }) } for ($i = $S.SvcJobs.Count - 1; $i -ge 0; $i--) { $j = $S.SvcJobs[$i] $done = $j.Task.IsCompleted if (-not $done -and ($now - $j.T0).TotalMilliseconds -lt $ServiceTimeoutMs) { continue } if ($done -and -not $j.Task.IsFaulted -and -not $j.Task.IsCanceled -and $j.Client.Connected) { $j.Host.Ports.Add($j.Port) } try { $j.Client.Close() } catch {} $S.SvcJobs.RemoveAt($i) } if ($S.SvcIdx -ge $S.SvcQueue.Count -and $S.SvcJobs.Count -eq 0) { $S.SvcPhase = 2 } } # 6) Fin de scan if ($S.NbPhase -eq 2 -and $S.SvcPhase -eq 2 -and $S.DnsJobs.Count -eq 0) { $S.Running = $false $S.Ended = [datetime]::Now Update-MacAddresses Complete-Scan } } # Construit les lignes affichees / exportees une fois le scan termine function Complete-Scan { $S.Duration = ($S.Ended - $S.Started).TotalSeconds $selfIps = @{}; $gateways = @{} foreach ($n in $S.Net) { $selfIps[$n.Ip] = $n foreach ($g in $n.Gateways) { $gateways[$g] = $true } } # Interface utilisee : celle dont le sous-reseau contient la cible, sinon celle qui a une passerelle $nic = $null foreach ($n in $S.Net) { if (($S.Range.First -band $n.Mask) -eq $n.Net) { $nic = $n; break } } if ($null -eq $nic) { $nic = $S.Net | Where-Object { $_.Gateways.Count -gt 0 } | Select-Object -First 1 } $S.Interface = if ($nic) { '{0} ({1})' -f $nic.Name, $nic.Ip } else { '-' } $rows = [System.Collections.Generic.List[object]]::new() foreach ($h in @($S.Up | Sort-Object Num)) { $isMe = $selfIps.ContainsKey($h.IP) $isGw = $gateways.ContainsKey($h.IP) if (-not $h.MAC -and $isMe) { $h.MAC = $selfIps[$h.IP].Mac } $short = '' if ($h.Hote -and $h.Hote -ne '...' -and $h.Hote -ne '-') { $short = ($h.Hote -split '\.')[0] } $name = $short if (-not $name -and $isMe) { $name = [Environment]::MachineName.ToLower() } if (-not $name -and $isGw) { $name = 'gateway' } if (-not $name) { $name = '-' } if ($isGw) { $name += ' (gw)' } if ($isMe) { $name += ' (me)' } $ports = @($h.Ports | Sort-Object) $rows.Add([pscustomobject]@{ Ip = $h.IP Status = 'ONLINE' Name = $name Hostname = if ($h.Hote -and $h.Hote -ne '...') { $h.Hote } else { '-' } Mac = if ($h.MAC) { $h.MAC } else { '-' } Vendor = (Get-Vendor $h.MAC) Services = if ($ports.Count -gt 0) { $ports -join ', ' } else { '-' } Ports = $ports DetectedBy = $h.Via RttMs = $h.RTT Attempts = $h.Tries }) } $S.Rows = $rows.ToArray() } #endregion #region ---------- Export (JSON / CSV / Markdown) ---------- function Format-ScanDate { $S.Started.ToString('dd/MM/yyyy', $INV) + ' at ' + $S.Started.ToString('HH:mm:ss', $INV) } function Format-Duration { $S.Duration.ToString('0.0', $INV) + ' seconds' } function Export-Scan([string]$Format) { $stamp = $S.Started.ToString('yyyyMMdd_HHmmss', $INV) $ext = switch ($Format) { 'json' { 'json' } 'csv' { 'csv' } default { 'md' } } $file = 'ipscan_{0}_{1}.{2}' -f ($S.Range.Cidr -replace '[/\.]', '_'), $stamp, $ext if (-not (Test-Path -LiteralPath $ExportDir)) { New-Item -ItemType Directory -Path $ExportDir -Force | Out-Null } $path = Join-Path $ExportDir $file $rows = @($S.Rows) switch ($Format) { 'json' { $hosts = @(foreach ($r in $rows) { [ordered]@{ ip = $r.Ip; status = $r.Status; name = $r.Name; hostname = $r.Hostname mac = $r.Mac; vendor = $r.Vendor ports = [int[]]$r.Ports detectedBy = $r.DetectedBy; rttMs = $r.RttMs; attempts = $r.Attempts } }) $obj = [ordered]@{ tool = 'Infraback CLI ToolKit - IPScanner' target = $S.Range.Cidr interface = $S.Interface scanTime = $S.Started.ToString('o', $INV) durationSeconds = [Math]::Round($S.Duration, 1) hostsDiscovered = $rows.Count targetsAnalyzed = $S.Range.Count hosts = $hosts } $json = ConvertTo-Json -InputObject $obj -Depth 5 [System.IO.File]::WriteAllText($path, $json, (New-Object System.Text.UTF8Encoding($false))) } 'csv' { $csv = @($rows | ForEach-Object { [pscustomobject]@{ IP = $_.Ip; Status = $_.Status; Name = $_.Name; Hostname = $_.Hostname; MAC = $_.Mac Vendor = $_.Vendor; Ports = ($_.Ports -join ' ') DetectedBy = $_.DetectedBy; RttMs = $_.RttMs; Attempts = $_.Attempts } }) if ($csv.Count -eq 0) { $text = '"IP";"Status";"Name";"Hostname";"MAC";"Vendor";"Ports";"DetectedBy";"RttMs";"Attempts"' + "`r`n" } else { $text = (($csv | ConvertTo-Csv -Delimiter ';' -NoTypeInformation) -join "`r`n") + "`r`n" } [System.IO.File]::WriteAllText($path, $text, (New-Object System.Text.UTF8Encoding($true))) # BOM : accents corrects dans Excel } default { $esc = { param($t) ([string]$t) -replace '\|', '\|' } $nl = "`r`n" $sb = [System.Text.StringBuilder]::new() [void]$sb.Append('# Infraback IPScanner report' + $nl + $nl) [void]$sb.Append("- **Target**: $($S.Range.Cidr)" + $nl) [void]$sb.Append("- **Interface**: $($S.Interface)" + $nl) [void]$sb.Append("- **Scan time**: $(Format-ScanDate)" + $nl) [void]$sb.Append("- **Duration**: $(Format-Duration)" + $nl) [void]$sb.Append("- **Result**: $($rows.Count) hosts discovered out of $($S.Range.Count) targets analyzed" + $nl + $nl) [void]$sb.Append('| IP | Status | Name | MAC | Vendor | Services |' + $nl) [void]$sb.Append('|----|--------|------|-----|--------|----------|' + $nl) foreach ($r in $rows) { [void]$sb.Append(('| {0} | {1} | {2} | {3} | {4} | {5} |' -f (& $esc $r.Ip), (& $esc $r.Status), (& $esc $r.Name), (& $esc $r.Mac), (& $esc $r.Vendor), (& $esc $r.Services)) + $nl) } [System.IO.File]::WriteAllText($path, $sb.ToString(), (New-Object System.Text.UTF8Encoding($false))) } } return $path } #endregion #region ---------- Rendu TUI ---------- $script:LastSig = @{} # cache : une ligne n'est reecrite que si son contenu change (pas de scintillement) # Couleurs '#RRGGBB' : sequences ANSI 24 bits si la console les gere (Windows Terminal, conhost Windows 10+), # sinon repli sur la ConsoleColor la plus proche. $script:Vt = $false try { $script:Vt = [bool]$Host.UI.SupportsVirtualTerminal } catch {} if (-not $script:Vt) { try { Add-Type -Namespace Infraback -Name ConsoleMode -MemberDefinition @' [DllImport("kernel32.dll")] public static extern IntPtr GetStdHandle(int nStdHandle); [DllImport("kernel32.dll")] public static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); [DllImport("kernel32.dll")] public static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); '@ $hOut = [Infraback.ConsoleMode]::GetStdHandle(-11) # STD_OUTPUT_HANDLE $mode = [uint32]0 if ([Infraback.ConsoleMode]::GetConsoleMode($hOut, [ref]$mode)) { # ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x4 $script:Vt = [Infraback.ConsoleMode]::SetConsoleMode($hOut, [uint32]($mode -bor 4)) } } catch {} } # Palette ConsoleColor de reference pour le repli (index = valeur de l'enum) $script:ConsolePalette = @( @(0, 0, 0), @(0, 0, 128), @(0, 128, 0), @(0, 128, 128), @(128, 0, 0), @(128, 0, 128), @(128, 128, 0), @(192, 192, 192), @(128, 128, 128), @(0, 0, 255), @(0, 255, 0), @(0, 255, 255), @(255, 0, 0), @(255, 0, 255), @(255, 255, 0), @(255, 255, 255) ) function Test-HexColor($Color) { ($Color -is [string]) -and $Color -match '^#[0-9A-Fa-f]{6}$' } function Get-NearestConsoleColor([int]$R, [int]$G, [int]$B) { $best = 0; $bestD = [int]::MaxValue for ($i = 0; $i -lt 16; $i++) { $c = $script:ConsolePalette[$i] $d = ($R - $c[0]) * ($R - $c[0]) + ($G - $c[1]) * ($G - $c[1]) + ($B - $c[2]) * ($B - $c[2]) if ($d -lt $bestD) { $best = $i; $bestD = $d } } [ConsoleColor]$best } # Applique une couleur (nom ConsoleColor ou '#RRGGBB'). Renvoie $true si une sequence ANSI a ete emise. function Set-SegColor($Color, [bool]$Back) { if (Test-HexColor $Color) { $r = [Convert]::ToInt32($Color.Substring(1, 2), 16) $g = [Convert]::ToInt32($Color.Substring(3, 2), 16) $b = [Convert]::ToInt32($Color.Substring(5, 2), 16) if ($script:Vt) { [Console]::Write(('{0}[{1};2;{2};{3};{4}m' -f $ESC, $(if ($Back) { 48 } else { 38 }), $r, $g, $b)) return $true } $Color = Get-NearestConsoleColor $r $g $b } if ($Back) { [Console]::BackgroundColor = $Color } else { [Console]::ForegroundColor = $Color } return $false } # Segment de texte : $F / $B = nom ConsoleColor ('White', 'DarkGray'...) ou couleur hexadecimale '#RRGGBB' function Seg([string]$T, $F = $null, $B = $null) { foreach ($c in $F, $B) { if ($c -is [string] -and $c.StartsWith('#') -and -not (Test-HexColor $c)) { throw "Invalid color '$c'. Expected #RRGGBB." } } [pscustomobject]@{ T = $T; F = $F; B = $B } } function Write-Line([int]$Y, $Segs) { if ($Y -lt 0 -or $Y -ge [Console]::WindowHeight) { return } $w = [Console]::WindowWidth - 1 $list = @($Segs | Where-Object { $null -ne $_ }) $sig = (($list | ForEach-Object { '{0}|{1}|{2}' -f $_.T, $_.F, $_.B }) -join '~') if ($script:LastSig.ContainsKey($Y) -and $script:LastSig[$Y] -eq $sig) { return } $script:LastSig[$Y] = $sig [Console]::SetCursorPosition(0, $Y) $used = 0 foreach ($sg in $list) { $t = $sg.T if ($used + $t.Length -gt $w) { $t = $t.Substring(0, [Math]::Max(0, $w - $used)) } if ($t.Length -eq 0) { continue } [Console]::ResetColor() $ansi = $false if ($sg.F) { if (Set-SegColor $sg.F $false) { $ansi = $true } } if ($sg.B) { if (Set-SegColor $sg.B $true) { $ansi = $true } } [Console]::Write($t) if ($ansi) { [Console]::Write($ESC + '[0m') } $used += $t.Length } [Console]::ResetColor() if ($used -lt $w) { [Console]::Write(' ' * ($w - $used)) } } function Fit([string]$Text, [int]$Width) { if ($Width -lt 2) { return '' } if ($Text.Length -ge $Width) { return $Text.Substring(0, $Width - 1) + ' ' } return $Text.PadRight($Width) } function Get-Cols([int]$W) { # Largeurs de la maquette sur un terminal large ; colonnes compactees ensuite pour que SERVICES reste lisible if ($W -ge 142) { $c = 17, 11, 27, 27, 27 } elseif ($W -ge 104) { $c = 17, 11, 25, 19, 26 } else { $c = 16, 9, 18, 18, 18 } $rest = $W - 1 - ($c[0] + $c[1] + $c[2] + $c[3] + $c[4]) @{ Ip = $c[0]; St = $c[1]; Name = $c[2]; Mac = $c[3]; Vendor = $c[4]; Svc = [Math]::Max(8, $rest) } } function Get-ItemIds { switch ($UI.Screen) { 'menu' { return @('ipscan', 'quit') } 'start' { return @('target', 'start', 'back') } 'scan' { return @('stop') } 'result' { return @('export', 'restart', 'exit', 'tomenu') } 'export' { return @('json', 'csv', 'md', 'back') } } return @() } function Get-ItemText([string]$Id, [bool]$Selected) { switch ($Id) { 'target' { if ($UI.Edit) { return "> $($UI.Buf)_" } if ($Selected) { return "> $($UI.Target)" } return " $($UI.Target)" } 'ipscan' { return '1. IP Scanner' } 'quit' { return 'x Exit' } 'start' { return "$ARROW Start a scan" } 'back' { return '< Back' } 'stop' { return '< Stop and back' } 'export' { return "$ARROW Export scan" } 'restart' { return "$ARROW Restart a scan" } 'exit' { return 'x Exit ToolKit' } 'tomenu' { return '< Back to menu' } 'json' { return 'JSON' } 'csv' { return 'CSV' } 'md' { return 'Markdown' } } return $Id } function Show-Screen { $w = [Console]::WindowWidth; $h = [Console]::WindowHeight $L = New-Object 'object[]' $h if ($w -lt 70 -or $h -lt 20) { $L[0] = @(Seg 'Window too small (min 70x20).' 'Yellow') } else { $hr = $RULE * ($w - 1) # --- En-tete commun (lignes 0 a 3) --- if ($UI.Edit) { $L[0] = @((Seg 'Enter' 'White'), (Seg ' to confirm' 'DarkGray'), (Seg ' '), (Seg 'Esc' 'White'), (Seg ' to cancel' 'DarkGray')) } else { $help = @((Seg 'Enter' 'White'), (Seg ' to select' 'DarkGray'), (Seg ' '), (Seg "$UPA and $DNA to navigate" 'DarkGray')) if ($UI.Screen -eq 'result' -and @($S.Rows).Count -gt [Math]::Max(3, $h - 18)) { $help += (Seg ' PgUp/PgDn to scroll the table' 'DarkGray') } $L[0] = $help } $L[1] = @(Seg $hr 'Gray') $L[2] = @(Seg $(if ($UI.Screen -eq 'menu') { 'Infraback CLI ToolKit' } else { 'Infraback CLI ToolKit - IPScanner' }) 'Gray') $L[3] = @(Seg $hr 'Gray') $ids = @(Get-ItemIds) if ($UI.Sel -ge $ids.Count) { $UI.Sel = 0 } $itemRows = @() switch ($UI.Screen) { 'menu' { $itemRows = 5, 7 } 'start' { $L[5] = @(Seg 'Target IP :') $itemRows = 6, 8, 9 if ($UI.Msg) { $L[11] = @(Seg $UI.Msg 'Red') } if ($UI.Warn) { $L[13] = @(Seg $UI.Warn 'Yellow') } } 'export' { $L[5] = @(Seg 'Export format :') $itemRows = 6, 7, 8, 10 if ($UI.Msg) { $L[12] = @(Seg $UI.Msg $(if ($UI.MsgOk) { 'Green' } else { 'Red' })) } } 'scan' { $total = [Math]::Max(1, [int]$S.Range.Count) $pct = [Math]::Min(100, [int][Math]::Floor(100 * $S.Scanned / $total)) $barW = 25 $filled = [int][Math]::Floor($pct * $barW / 100) $spinCh = $SPIN[[int]([datetime]::Now.TimeOfDay.TotalMilliseconds / 300) % 2] $verb = 'Scanning of' if ($S.NbPhase -eq 1) { $verb = 'Checking ARP cache for' } elseif ($S.SvcPhase -eq 1) { $verb = 'Detecting services on' } $L[5] = @(Seg "$spinCh $verb $($S.Range.Cidr) ...") $L[6] = @((Seg ('({0}/{1})... [' -f $S.Scanned, $S.Range.Count)), (Seg ($BLOCK * $filled) 'Gray'), (Seg ($SHADE * ($barW - $filled)) 'DarkGray'), (Seg "] $pct%")) $n = $S.Up.Count $L[7] = @(Seg ('Found: {0} host{1}' -f $n, $(if ($n -ne 1) { 's' } else { '' }))) $itemRows = @(9) } 'result' { $rows = @($S.Rows) $L[4] = @(Seg "[i] Target : $($S.Range.Cidr)") $L[5] = @(Seg "[i] Interface : $($S.Interface)") $L[6] = @(Seg "[i] Scan Time : $(Format-ScanDate)") $L[7] = @(Seg "[i] Duration : $(Format-Duration)") $L[8] = @(Seg $hr 'Gray') $c = Get-Cols $w $L[9] = @((Seg (Fit 'IP' $c.Ip)), (Seg (Fit 'STATUS' $c.St)), (Seg (Fit 'NAME' $c.Name)), (Seg (Fit 'MAC' $c.Mac)), (Seg (Fit 'VENDOR' $c.Vendor)), (Seg 'SERVICES')) $u = $RULE * 2 $L[10] = @((Seg (Fit $u $c.Ip)), (Seg (Fit $u $c.St)), (Seg (Fit $u $c.Name)), (Seg (Fit $u $c.Mac)), (Seg (Fit $u $c.Vendor)), (Seg $u)) $maxRows = [Math]::Max(3, $h - 18) $visible = [Math]::Min([Math]::Max($rows.Count, 1), $maxRows) $UI.PageSize = $visible $UI.Scroll = [Math]::Max(0, [Math]::Min($UI.Scroll, $rows.Count - $visible)) if ($rows.Count -eq 0) { $L[11] = @(Seg 'No host discovered.' 'DarkGray') } else { for ($i = 0; $i -lt $visible; $i++) { $r = $rows[$UI.Scroll + $i] $L[11 + $i] = @( (Seg (Fit $r.Ip $c.Ip)), (Seg (Fit $r.Status $c.St) 'Green'), (Seg (Fit $r.Name $c.Name)), (Seg (Fit $r.Mac $c.Mac)), (Seg (Fit $r.Vendor $c.Vendor)), (Seg $r.Services) ) } } $b = 11 + $visible $L[$b] = @(Seg $hr 'Gray') $n = $rows.Count $L[$b + 1] = @((Seg '[Scan complete]' 'Green'), (Seg (' : {0} host{1} discovered out of {2} targets analyzed.' -f $n, $(if ($n -ne 1) { 's' } else { '' }), $S.Range.Count))) $itemRows = ($b + 3), ($b + 4), ($b + 5), ($b + 6) } } for ($i = 0; $i -lt $ids.Count; $i++) { $sel = ($i -eq $UI.Sel) $text = Get-ItemText $ids[$i] $sel if ($itemRows[$i] -lt $h) { if ($sel) { $L[$itemRows[$i]] = @(Seg $text 'Black' 'Gray') } else { $L[$itemRows[$i]] = @(Seg $text) } } } } for ($y = 0; $y -lt $h; $y++) { Write-Line $y $L[$y] } } #endregion #region ---------- Clavier ---------- function Invoke-Action([string]$Id) { switch ($Id) { 'ipscan' { $UI.Screen = 'start'; $UI.Sel = 0; $UI.Msg = '' if (-not $script:OuiTried) { $script:OuiTried = $true if (Test-Path -LiteralPath $OuiPath) { Import-Oui } else { $UI.Warn = "Vendor database not found ($OuiPath): the VENDOR column will be empty." } } } 'quit' { $script:Quit = $true } 'target' { $UI.Edit = $true; $UI.Buf = $UI.Target; $UI.Msg = '' } 'start' { try { Start-Scan $UI.Target; $UI.Screen = 'scan'; $UI.Sel = 0; $UI.Msg = '' } catch { $UI.Msg = $_.Exception.Message; $UI.MsgOk = $false } } 'back' { if ($UI.Screen -eq 'export') { $UI.Screen = 'result'; $UI.Sel = 0; $UI.Msg = '' } else { $UI.Screen = 'menu'; $UI.Sel = 0; $UI.Msg = '' } # retour au menu du ToolKit } 'stop' { Stop-Scan; $UI.Screen = 'start'; $UI.Sel = 1; $UI.Msg = '' } 'export' { $UI.Screen = 'export'; $UI.Sel = 0; $UI.Msg = '' } 'restart' { try { Start-Scan $UI.Target; $UI.Screen = 'scan'; $UI.Sel = 0; $UI.Scroll = 0 } catch { $UI.Msg = $_.Exception.Message; $UI.MsgOk = $false } } 'exit' { $script:Quit = $true } 'tomenu' { Stop-Scan; $UI.Screen = 'menu'; $UI.Sel = 0; $UI.Msg = ''; $UI.Scroll = 0 } { $_ -in 'json', 'csv', 'md' } { try { $p = Export-Scan $Id; $UI.Msg = "Exported: $p"; $UI.MsgOk = $true } catch { $UI.Msg = "Export failed: $($_.Exception.Message)"; $UI.MsgOk = $false } } } } function Invoke-Key($k) { # Saisie de la plage CIDR if ($UI.Edit) { switch ($k.Key) { 'Enter' { try { $r = Get-CidrRange $UI.Buf; $UI.Target = $r.Cidr; $UI.Edit = $false; $UI.Msg = '' } catch { $UI.Msg = $_.Exception.Message; $UI.MsgOk = $false } } 'Escape' { $UI.Edit = $false; $UI.Msg = '' } 'Backspace' { if ($UI.Buf.Length -gt 0) { $UI.Buf = $UI.Buf.Substring(0, $UI.Buf.Length - 1) } } default { if ("$($k.KeyChar)" -match '^[0-9./]$' -and $UI.Buf.Length -lt 18) { $UI.Buf += [string]$k.KeyChar } } } return } $ids = @(Get-ItemIds) switch ($k.Key) { 'UpArrow' { $UI.Sel = ($UI.Sel - 1 + $ids.Count) % $ids.Count } 'DownArrow' { $UI.Sel = ($UI.Sel + 1) % $ids.Count } 'PageUp' { if ($UI.Screen -eq 'result') { $UI.Scroll = [Math]::Max(0, $UI.Scroll - $UI.PageSize) } } 'PageDown' { if ($UI.Screen -eq 'result') { $UI.Scroll += $UI.PageSize } } 'Enter' { Invoke-Action $ids[$UI.Sel] } 'Escape' { switch ($UI.Screen) { 'menu' { Invoke-Action 'quit' } 'start' { Invoke-Action 'back' } 'scan' { Invoke-Action 'stop' } 'result' { Invoke-Action 'tomenu' } 'export' { Invoke-Action 'back' } } } } } #endregion # --- MAIN --- #region ---------- Programme principal ---------- try { if ($Cidr) { $UI.Target = (Get-CidrRange $Cidr).Cidr } else { $UI.Target = Get-DefaultCidr } [Console]::CursorVisible = $false [Console]::Clear() $lastW = [Console]::WindowWidth; $lastH = [Console]::WindowHeight $lastDraw = [datetime]::MinValue $dirty = $true while (-not $script:Quit) { if ($S.Running) { Invoke-ScanStep } while ([Console]::KeyAvailable) { Invoke-Key ([Console]::ReadKey($true)) $dirty = $true } # Scan termine : bascule automatique vers l'ecran de resultats if ($UI.Screen -eq 'scan' -and -not $S.Running -and $null -ne $S.Ended) { $UI.Screen = 'result'; $UI.Sel = 0; $UI.Scroll = 0; $dirty = $true } if ([Console]::WindowWidth -ne $lastW -or [Console]::WindowHeight -ne $lastH) { $lastW = [Console]::WindowWidth; $lastH = [Console]::WindowHeight [Console]::Clear(); $script:LastSig.Clear(); $dirty = $true } $now = [datetime]::Now if ($UI.Screen -eq 'scan' -and ($now - $lastDraw).TotalMilliseconds -ge 120) { $dirty = $true } # animation if ($dirty -and ($now - $lastDraw).TotalMilliseconds -ge 40) { Show-Screen $lastDraw = $now; $dirty = $false } Start-Sleep -Milliseconds 15 } } catch { [Console]::ResetColor(); [Console]::CursorVisible = $true Write-Host "`nError: $($_.Exception.Message)" -ForegroundColor Red exit 1 } finally { Stop-Scan [Console]::ResetColor() [Console]::CursorVisible = $true try { [Console]::Clear() } catch {} } #endregion