11#!/usr/bin/env python3
22
3- from rich .console import Console
4- from rich .panel import Panel
5- import subprocess
6- import requests
7- import platform
83import argparse
9- import shutil
10- import sys
114import os
5+ import os .path
6+ import platform
127import re
8+ import shutil
9+ import signal
10+ import subprocess
11+ import sys
12+ import tempfile
13+ from concurrent .futures import ThreadPoolExecutor
14+ from functools import partial
15+ from threading import Event
16+ from typing import Iterable , Tuple
17+ from urllib .request import urlopen
18+
19+ import requests
20+ from rich .color import Color
21+ from rich .console import Console
22+ from rich .highlighter import RegexHighlighter
23+ from rich .panel import Panel
24+ from rich .progress import (BarColumn , DownloadColumn , Progress , TaskID ,
25+ TextColumn , TimeRemainingColumn ,
26+ TransferSpeedColumn )
27+ from rich .table import Table
28+ from rich .text import Text
29+ from rich .theme import Theme
1330
1431PROGRAM = "port-scanner"
1532DESCRIPTION = "An enhanced Nmap wrapper"
16- VERSION = "0.1.2 "
33+ VERSION = "0.1.3 "
1734
1835console = Console ()
19- error_console = Console (stderr = True , style = "bold red" )
2036
2137
2238def update ():
@@ -27,13 +43,52 @@ def update():
2743 install_nmap (force = True )
2844
2945
30- def get_latest_nmap_url ():
46+ def run_nmap (* nmap_args : str ) -> None :
47+ cmd = ["nmap" , * nmap_args ]
48+
49+ process = subprocess .run (cmd , capture_output = True , text = True )
50+ output = process .stdout + process .stderr
51+
52+ section_headers = [
53+ r"TARGET SPECIFICATION:" ,
54+ r"HOST DISCOVERY:" ,
55+ r"SCAN TECHNIQUES:" ,
56+ r"PORT SPECIFICATION AND SCAN ORDER:" ,
57+ r"SERVICE/VERSION DETECTION:" ,
58+ r"SCRIPT SCAN:" ,
59+ r"OS DETECTION:" ,
60+ r"TIMING AND PERFORMANCE:" ,
61+ r"FIREWALL/IDS EVASION AND SPOOFING:" ,
62+ r"OUTPUT:" ,
63+ r"MISC:" ,
64+ r"EXAMPLES:" ,
65+ r"SEE THE MAN PAGE"
66+ ]
67+
68+ for header in section_headers :
69+ output = re .sub (rf"(?m)^({ header } )" , r"\n\1" , output )
70+
71+ text = Text (output )
72+ text .highlight_regex (r"\bopen\b" , "green" )
73+ text .highlight_regex (r"\bclosed\b" , "red" )
74+ text .highlight_regex (r"\bfiltered\b" , "yellow" )
75+
76+ panel = Panel (
77+ text ,
78+ border_style = "dim" ,
79+ title = " " .join (cmd ),
80+ title_align = "left" ,
81+ )
82+ console .print (panel )
83+
84+
85+ def get_nmap_url ():
3186 url = "https://nmap.org/dist/"
3287 resp = requests .get (url )
3388 links = re .findall (r'href="(nmap-(\d+\.\d+)-setup\.exe)"' , resp .text )
3489 if not links :
3590 return None
36- latest = max (links , key = lambda x : tuple (map (int , x [1 ].split ('.' ))))
91+ latest = max (links , key = lambda x : tuple (map (int , x [1 ].split ("." ))))
3792 return url + latest [0 ]
3893
3994
@@ -49,105 +104,186 @@ def install_nmap(force=False):
49104 system = platform .system ()
50105 if system == "Linux" :
51106 if shutil .which ("apt-get" ):
52- subprocess .run (["sudo" , " apt-get" , "update" ], check = True )
53- subprocess .run (["sudo" , " apt-get" , "install" , "-y" , "nmap" ], check = True )
107+ subprocess .run (["apt-get" , "update" ], check = True )
108+ subprocess .run (["apt-get" , "install" , "-y" , "nmap" ], check = True )
54109 elif shutil .which ("dnf" ):
55- subprocess .run (["sudo" , " dnf" , "install" , "-y" , "nmap" ], check = True )
110+ subprocess .run (["dnf" , "install" , "-y" , "nmap" ], check = True )
56111 elif shutil .which ("yum" ):
57- subprocess .run (["sudo" , " yum" , "install" , "-y" , "nmap" ], check = True )
112+ subprocess .run (["yum" , "install" , "-y" , "nmap" ], check = True )
58113 else :
59- error_console .print (
60- "No supported package manager found. Please install nmap manually."
61- )
114+ raise RuntimeError ("No supported package manager found. Please install Nmap manually." )
62115
63116 elif system == "Windows" :
64- url = get_latest_nmap_url ()
117+ url = get_nmap_url ()
65118 if not url :
66- error_console .log ("Failed to find the latest Nmap installer URL." )
67- sys .exit (1 )
119+ raise RuntimeError ("Failed to find the latest Nmap installer URL." )
120+
121+ tmp_dir = tempfile .gettempdir ()
122+ filename = url .split ("/" )[- 1 ]
123+ dest_path = os .path .join (tmp_dir , filename )
68124
69- tmp_dir = os . environ . get ( "TEMP" , "/tmp" )
70- installer_path = os . path . join ( tmp_dir , "nmap-setup.exe" )
125+ downloader = Downloader ( )
126+ downloader . download ([ url ], tmp_dir )
71127
72- console .print (f"Downloading { url } " )
73- with requests .get (url , stream = True ) as r :
74- r .raise_for_status ()
75- with open (installer_path , 'wb' ) as f :
76- for chunk in r .iter_content (chunk_size = 8192 ):
77- f .write (chunk )
128+ console .print ("Starting Nmap installer." )
129+ console .print ("Please complete the installation manually." )
130+ subprocess .Popen (["start" , "" , dest_path ], shell = True )
78131
79- subprocess .Popen (["start" , "" , installer_path ], shell = True )
80- console .print ("Please complete the Nmap installation manually." )
132+ elif system == "Darwin" : # macOS
133+ if shutil .which ("brew" ):
134+ subprocess .run (["brew" , "install" , "nmap" ], check = True )
135+ else :
136+ raise RuntimeError ("Homebrew not found. Please install Homebrew first." )
81137
138+ class Downloader :
139+ def __init__ (self ):
140+ self .progress = Progress (
141+ TextColumn ("[bold blue]{task.fields[filename]}" , justify = "right" ),
142+ BarColumn (bar_width = None ),
143+ "[progress.percentage]{task.percentage:>3.1f}%" ,
144+ "•" ,
145+ DownloadColumn (),
146+ "•" ,
147+ TransferSpeedColumn (),
148+ "•" ,
149+ TimeRemainingColumn (),
150+ )
151+ self .done_event = Event ()
152+ signal .signal (signal .SIGINT , self .handle_sigint )
82153
83- def run_nmap (args ):
84- cmd = ["nmap" ] + args
85- result = subprocess .run (cmd , capture_output = True , text = True , check = True )
86- output = result .stdout .strip ()
154+ def handle_sigint (self , signum , frame ):
155+ self .done_event .set ()
87156
88- colored_output = []
89- for line in output .splitlines ():
90- line = re .sub (r"\bopen\b" , "[green]open[/]" , line )
91- line = re .sub (r"\bclosed\b" , "[red]closed[/]" , line )
92- line = re .sub (r"\bfiltered\b" , "[yellow]filtered[/]" , line )
93- colored_output .append (line )
157+ def copy_url (self , task_id : TaskID , url : str , path : str ) -> None :
158+ """Copy data from a URL to a local file."""
159+ self .progress .console .log (f"Requesting { url } " )
160+ response = urlopen (url )
94161
95- styled_output = "\n " .join (colored_output )
162+ # This will break if the response doesn't contain content length
163+ try :
164+ total = int (response .info ()["Content-length" ])
165+ except (KeyError , TypeError ):
166+ total = None # Unknown total size
96167
97- console .print (
98- Panel (styled_output , title = "nmap " + " " .join (args ), border_style = "cyan" , width = 100 )
99- )
168+ self .progress .update (task_id , total = total )
100169
170+ with open (path , "wb" ) as dest_file :
171+ self .progress .start_task (task_id )
172+ for data in iter (partial (response .read , 32768 ), b"" ):
173+ dest_file .write (data )
174+ self .progress .update (task_id , advance = len (data ))
175+ if self .done_event .is_set ():
176+ return
177+ self .progress .console .log (f"Downloaded { path } " )
101178
102- def parse_args ():
179+ def download (self , urls : Iterable [str ], dest_dir : str ):
180+ """Download multiple files to the given directory."""
181+ with self .progress :
182+ with ThreadPoolExecutor (max_workers = 4 ) as pool :
183+ for url in urls :
184+ filename = url .split ("/" )[- 1 ]
185+ dest_path = os .path .join (dest_dir , filename )
186+ task_id = self .progress .add_task ("download" , filename = filename , start = False )
187+ pool .submit (self .copy_url , task_id , url , dest_path )
188+
189+ class RichCLI :
190+ @staticmethod
191+ def blend_text (
192+ message : str , color1 : Tuple [int , int , int ], color2 : Tuple [int , int , int ]
193+ ) -> Text :
194+ """Blend text from one color to another."""
195+ text = Text (message )
196+ r1 , g1 , b1 = color1
197+ r2 , g2 , b2 = color2
198+ dr = r2 - r1
199+ dg = g2 - g1
200+ db = b2 - b1
201+ size = len (text )
202+ for index in range (size ):
203+ blend = index / size
204+ color = f"#{ int (r1 + dr * blend ):02X} { int (g1 + dg * blend ):02X} { int (b1 + db * blend ):02X} "
205+ text .stylize (color , index , index + 1 )
206+ return text
207+
208+ @staticmethod
209+ def print_help (parser : argparse .ArgumentParser ) -> None :
210+ class OptionHighlighter (RegexHighlighter ):
211+ highlights = [
212+ r"(?P<switch>\-\w)" ,
213+ r"(?P<option>\-\-[\w\-]+)" ,
214+ ]
215+
216+ highlighter = OptionHighlighter ()
217+ rich_console = Console (
218+ theme = Theme ({"option" : "bold cyan" , "switch" : "bold green" }),
219+ highlighter = highlighter ,
220+ )
221+
222+ console .print (
223+ f"\n [b]{ PROGRAM } [/b] [magenta]v{ VERSION } [/] 🔍\n [dim]{ DESCRIPTION } \n " ,
224+ justify = "center" ,
225+ )
226+ console .print (f"Usage: [b]{ PROGRAM } [/b] [b][Options][/] [b cyan]<...>\n " )
227+
228+ table = Table (highlight = True , box = None , show_header = False )
229+ for action in parser ._actions :
230+ if not action .option_strings :
231+ continue
232+ opts = [highlighter (opt ) for opt in action .option_strings ]
233+ help_text = Text (action .help or "" )
234+ if action .metavar :
235+ opts [- 1 ] += Text (f" { action .metavar } " , style = "bold yellow" )
236+ table .add_row (* opts , help_text )
237+
238+ rich_console .print (
239+ Panel (table , border_style = "dim" , title = "Options" , title_align = "left" )
240+ )
241+
242+ footer_console = Console ()
243+ footer_console .print (
244+ RichCLI .blend_text (
245+ "batubyte.github.io" ,
246+ Color .parse ("#b169dd" ).triplet ,
247+ Color .parse ("#542c91" ).triplet ,
248+ ),
249+ justify = "right" ,
250+ style = "bold" ,
251+ )
252+
253+
254+ def main ():
103255 parser = argparse .ArgumentParser (
104256 prog = PROGRAM , description = DESCRIPTION , add_help = False
105257 )
106-
107- parser .add_argument (
108- "-v" , "--version" , action = "version" , version = f"%(prog)s version { VERSION } "
109- )
258+ parser .add_argument ("-h" , "--help" , action = "store_true" , help = "Show help message" )
259+ parser .add_argument ("-v" , "--version" , action = "store_true" , help = "Show version" )
110260 parser .add_argument (
111- "-h " , "--help " , action = "store_true" , help = "show this help message "
261+ "-u " , "--update " , action = "store_true" , help = "Update port-scanner and Nmap "
112262 )
113263 parser .add_argument (
114- "-u" , "--update" , action = "store_true" , help = "update port-scanner and nmap"
115- )
116- parser .add_argument (
117- "-n" , "--nmap" , nargs = argparse .REMAINDER , help = "run nmap with custom arguments"
264+ "-n" , "--nmap" , nargs = argparse .REMAINDER , help = "Run Nmap"
118265 )
119266
120- if len (sys .argv ) == 1 or '--help' in sys .argv or '-h' in sys .argv :
121- console .print (
122- Panel (
123- parser .format_help (),
124- title = " " .join (sys .argv ),
125- border_style = "cyan" ,
126- width = 80 ,
127- )
128- )
129- sys .exit ()
130-
131- return parser .parse_args ()
267+ if len (sys .argv ) == 1 or sys .argv [1 ] in ("?" , "-h" , "--help" ):
268+ RichCLI .print_help (parser )
269+ return
132270
271+ args = parser .parse_args ()
133272
134- def main ():
135- args = parse_args ()
273+ if args .version :
274+ console .print (f"{ PROGRAM } { VERSION } " )
275+ return
136276
137277 if args .update :
138278 update ()
139279
140280 if args .nmap is not None :
141281 install_nmap ()
142- if len (args .nmap ) == 0 :
143- run_nmap (["--help" ])
144- else :
145- run_nmap (args .nmap )
146-
282+ run_nmap (* args .nmap )
147283
148284if __name__ == "__main__" :
149285 try :
150286 main ()
151287 except Exception as e :
152- error_console . log ( f"Error: { e } " )
288+ console . print_exception ( show_locals = False )
153289 sys .exit (1 )
0 commit comments