1
1
#!/usr/bin/env python3
2
2
3
- from rich .console import Console
4
- from rich .panel import Panel
5
- import subprocess
6
- import requests
7
- import platform
8
3
import argparse
9
- import shutil
10
- import sys
11
4
import os
5
+ import os .path
6
+ import platform
12
7
import 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
13
30
14
31
PROGRAM = "port-scanner"
15
32
DESCRIPTION = "An enhanced Nmap wrapper"
16
- VERSION = "0.1.2 "
33
+ VERSION = "0.1.3 "
17
34
18
35
console = Console ()
19
- error_console = Console (stderr = True , style = "bold red" )
20
36
21
37
22
38
def update ():
@@ -27,13 +43,52 @@ def update():
27
43
install_nmap (force = True )
28
44
29
45
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 ():
31
86
url = "https://nmap.org/dist/"
32
87
resp = requests .get (url )
33
88
links = re .findall (r'href="(nmap-(\d+\.\d+)-setup\.exe)"' , resp .text )
34
89
if not links :
35
90
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 ("." ))))
37
92
return url + latest [0 ]
38
93
39
94
@@ -49,105 +104,186 @@ def install_nmap(force=False):
49
104
system = platform .system ()
50
105
if system == "Linux" :
51
106
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 )
54
109
elif shutil .which ("dnf" ):
55
- subprocess .run (["sudo" , " dnf" , "install" , "-y" , "nmap" ], check = True )
110
+ subprocess .run (["dnf" , "install" , "-y" , "nmap" ], check = True )
56
111
elif shutil .which ("yum" ):
57
- subprocess .run (["sudo" , " yum" , "install" , "-y" , "nmap" ], check = True )
112
+ subprocess .run (["yum" , "install" , "-y" , "nmap" ], check = True )
58
113
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." )
62
115
63
116
elif system == "Windows" :
64
- url = get_latest_nmap_url ()
117
+ url = get_nmap_url ()
65
118
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 )
68
124
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 )
71
127
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 )
78
131
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." )
81
137
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 )
82
153
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 ()
87
156
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 )
94
161
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
96
167
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 )
100
169
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 } " )
101
178
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 ():
103
255
parser = argparse .ArgumentParser (
104
256
prog = PROGRAM , description = DESCRIPTION , add_help = False
105
257
)
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" )
110
260
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 "
112
262
)
113
263
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"
118
265
)
119
266
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
132
270
271
+ args = parser .parse_args ()
133
272
134
- def main ():
135
- args = parse_args ()
273
+ if args .version :
274
+ console .print (f"{ PROGRAM } { VERSION } " )
275
+ return
136
276
137
277
if args .update :
138
278
update ()
139
279
140
280
if args .nmap is not None :
141
281
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 )
147
283
148
284
if __name__ == "__main__" :
149
285
try :
150
286
main ()
151
287
except Exception as e :
152
- error_console . log ( f"Error: { e } " )
288
+ console . print_exception ( show_locals = False )
153
289
sys .exit (1 )
0 commit comments