Skip to content

Commit cc32e6f

Browse files
authored
Update port_scanner.py
1 parent 08b9acf commit cc32e6f

File tree

1 file changed

+211
-75
lines changed

1 file changed

+211
-75
lines changed

port_scanner.py

Lines changed: 211 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,38 @@
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
83
import argparse
9-
import shutil
10-
import sys
114
import os
5+
import os.path
6+
import platform
127
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
1330

1431
PROGRAM = "port-scanner"
1532
DESCRIPTION = "An enhanced Nmap wrapper"
16-
VERSION = "0.1.2"
33+
VERSION = "0.1.3"
1734

1835
console = Console()
19-
error_console = Console(stderr=True, style="bold red")
2036

2137

2238
def 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

148284
if __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

Comments
 (0)