detecti-cli 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- detecti/__init__.py +0 -0
- detecti/cli.py +649 -0
- detecti/config.py +188 -0
- detecti/core/__init__.py +1 -0
- detecti/core/database/__init__.py +5 -0
- detecti/core/database/config_db.py +73 -0
- detecti/core/database/schema.py +136 -0
- detecti/core/database/storage.py +1388 -0
- detecti/core/engine.py +1032 -0
- detecti/core/models.py +278 -0
- detecti/data/config.sqlite +0 -0
- detecti/data/dbs/.gitkeep +2 -0
- detecti/data/dbs/example.com.sqlite +0 -0
- detecti/modules/__init__.py +29 -0
- detecti/modules/base.py +57 -0
- detecti/modules/censys.py +813 -0
- detecti/modules/crtsh.py +98 -0
- detecti/modules/exploitdb.py +138 -0
- detecti/modules/masscan.py +561 -0
- detecti/modules/nuclei.py +449 -0
- detecti/modules/nvd.py +300 -0
- detecti/modules/reverse_whois.py +225 -0
- detecti/modules/shodan.py +412 -0
- detecti/reporters/__init__.py +7 -0
- detecti/reporters/csv_reporter.py +74 -0
- detecti/reporters/html_reporter.py +356 -0
- detecti/reporters/json_reporter.py +26 -0
- detecti/reporters/markdown_reporter.py +203 -0
- detecti/utils/__init__.py +1 -0
- detecti/utils/http.py +294 -0
- detecti/utils/logger.py +378 -0
- detecti/utils/setup.py +453 -0
- detecti/web/__init__.py +6 -0
- detecti/web/api/__init__.py +1 -0
- detecti/web/api/auth.py +109 -0
- detecti/web/api/graph_builder.py +901 -0
- detecti/web/api/routes.py +1602 -0
- detecti/web/process_manager.py +283 -0
- detecti/web/server.py +183 -0
- detecti/web/static/android-chrome-192x192.png +0 -0
- detecti/web/static/android-chrome-512x512.png +0 -0
- detecti/web/static/apple-touch-icon.png +0 -0
- detecti/web/static/css/__init__.py +1 -0
- detecti/web/static/css/dashboard.css +3802 -0
- detecti/web/static/favicon-16x16.png +0 -0
- detecti/web/static/favicon-32x32.png +0 -0
- detecti/web/static/favicon.ico +0 -0
- detecti/web/static/img/DetecTI_Security_Logo.png +0 -0
- detecti/web/static/img/detecti-ico.png +0 -0
- detecti/web/static/index.html +677 -0
- detecti/web/static/js/__init__.py +1 -0
- detecti/web/static/js/api.js +177 -0
- detecti/web/static/js/cytoscape-cose-bilkent.js +458 -0
- detecti/web/static/js/cytoscape-dagre.js +397 -0
- detecti/web/static/js/cytoscape.min.js +31 -0
- detecti/web/static/js/dagre.min.js +3809 -0
- detecti/web/static/js/graph.js +7439 -0
- detecti/web/static/js/lucide.min.js +12 -0
- detecti/web/static/login.html +290 -0
- detecti/web/static/site.webmanifest +1 -0
- detecti_cli-2.0.0.dist-info/METADATA +554 -0
- detecti_cli-2.0.0.dist-info/RECORD +64 -0
- detecti_cli-2.0.0.dist-info/WHEEL +4 -0
- detecti_cli-2.0.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
"""Masscan active network port scanner module and runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("detecti.masscan")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_HTTP_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
|
|
18
|
+
|
|
19
|
+
TOP_100_PORTS = [
|
|
20
|
+
80, 23, 443, 21, 22, 25, 3389, 110, 445, 139, 143, 53, 135, 3306, 8080, 1723, 111, 995, 993, 5900,
|
|
21
|
+
1025, 587, 8888, 199, 1720, 465, 548, 113, 81, 6001, 10000, 514, 5060, 179, 1026, 2000, 8443, 8000,
|
|
22
|
+
32768, 554, 26, 1433, 49152, 2001, 515, 8008, 49154, 1027, 5666, 646, 5000, 5631, 631, 49153, 8081,
|
|
23
|
+
2049, 88, 79, 5800, 106, 2121, 6129, 625, 5009, 444, 902, 636, 49155, 2601, 7070, 512, 1080, 1028,
|
|
24
|
+
5555, 5432, 19, 7, 7443, 6000, 3000, 2002, 513, 5357, 544, 49156, 3689, 5051, 500, 1900, 2602, 5190,
|
|
25
|
+
3001, 49157, 8500, 1029, 903, 1755, 9100, 2604, 8082
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_port_ranges_excluding(
|
|
30
|
+
total_start: int = 0,
|
|
31
|
+
total_end: int = 65535,
|
|
32
|
+
excluded_ports: Optional[set[int] | list[int]] = None
|
|
33
|
+
) -> str:
|
|
34
|
+
"""Generate minimal contiguous port ranges excluding specified ports.
|
|
35
|
+
|
|
36
|
+
Example:
|
|
37
|
+
build_port_ranges_excluding(0, 65535, {80, 443})
|
|
38
|
+
-> "0-79,81-442,444-65535"
|
|
39
|
+
"""
|
|
40
|
+
ex_set = {int(p) for p in (excluded_ports or set()) if total_start <= int(p) <= total_end}
|
|
41
|
+
if not ex_set:
|
|
42
|
+
return f"{total_start}-{total_end}" if total_start != total_end else str(total_start)
|
|
43
|
+
|
|
44
|
+
sorted_excluded = sorted(ex_set)
|
|
45
|
+
ranges = []
|
|
46
|
+
current_start = total_start
|
|
47
|
+
|
|
48
|
+
for p in sorted_excluded:
|
|
49
|
+
if p > current_start:
|
|
50
|
+
if p - 1 == current_start:
|
|
51
|
+
ranges.append(str(current_start))
|
|
52
|
+
else:
|
|
53
|
+
ranges.append(f"{current_start}-{p - 1}")
|
|
54
|
+
current_start = p + 1
|
|
55
|
+
|
|
56
|
+
if current_start <= total_end:
|
|
57
|
+
if current_start == total_end:
|
|
58
|
+
ranges.append(str(current_start))
|
|
59
|
+
else:
|
|
60
|
+
ranges.append(f"{current_start}-{total_end}")
|
|
61
|
+
|
|
62
|
+
return ",".join(ranges)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def parse_port_spec_to_set(ports_spec: Optional[str]) -> set[int]:
|
|
66
|
+
"""Parse a port specification string into a discrete set of port integers."""
|
|
67
|
+
if not ports_spec or not ports_spec.strip():
|
|
68
|
+
return set(TOP_100_PORTS)
|
|
69
|
+
|
|
70
|
+
spec = ports_spec.strip()
|
|
71
|
+
if spec.startswith("-p"):
|
|
72
|
+
spec = spec[2:].strip()
|
|
73
|
+
|
|
74
|
+
if spec in ("-", "all", "0-65535", "-p0-65535"):
|
|
75
|
+
return set(range(0, 65536))
|
|
76
|
+
if spec in ("1-65535", "-p1-65535"):
|
|
77
|
+
return set(range(1, 65536))
|
|
78
|
+
|
|
79
|
+
if spec.startswith("--top-ports"):
|
|
80
|
+
parts = spec.split()
|
|
81
|
+
count = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 100
|
|
82
|
+
return set(TOP_100_PORTS[:min(count, len(TOP_100_PORTS))])
|
|
83
|
+
|
|
84
|
+
ports = set()
|
|
85
|
+
for chunk in spec.split(","):
|
|
86
|
+
chunk = chunk.strip()
|
|
87
|
+
if not chunk:
|
|
88
|
+
continue
|
|
89
|
+
if "-" in chunk:
|
|
90
|
+
try:
|
|
91
|
+
start_s, end_s = chunk.split("-", 1)
|
|
92
|
+
s = int(start_s.strip())
|
|
93
|
+
e = int(end_s.strip())
|
|
94
|
+
if s <= e:
|
|
95
|
+
ports.update(range(s, e + 1))
|
|
96
|
+
except ValueError:
|
|
97
|
+
continue
|
|
98
|
+
elif chunk.isdigit():
|
|
99
|
+
ports.add(int(chunk))
|
|
100
|
+
|
|
101
|
+
return ports
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def filter_ports_excluding(
|
|
105
|
+
ports_spec: Optional[str],
|
|
106
|
+
excluded_ports: Optional[set[int] | list[int]] = None
|
|
107
|
+
) -> tuple[Optional[str], int, int, set[int]]:
|
|
108
|
+
"""Filter out excluded ports from any port specification.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
(filtered_ports_arg, remaining_count, excluded_count, actual_excluded_set)
|
|
112
|
+
"""
|
|
113
|
+
excluded_set = {int(p) for p in (excluded_ports or set())}
|
|
114
|
+
if not ports_spec:
|
|
115
|
+
ports_spec = "--top-ports 100"
|
|
116
|
+
|
|
117
|
+
clean_spec = ports_spec.strip()
|
|
118
|
+
if clean_spec.startswith("-p"):
|
|
119
|
+
clean_spec = clean_spec[2:].strip()
|
|
120
|
+
|
|
121
|
+
is_all_ports = clean_spec in ("-", "all", "0-65535", "1-65535", "-p0-65535", "-p1-65535")
|
|
122
|
+
|
|
123
|
+
if is_all_ports:
|
|
124
|
+
start_p = 1 if "1-65535" in clean_spec else 0
|
|
125
|
+
actual_ex = {p for p in excluded_set if start_p <= p <= 65535}
|
|
126
|
+
filtered_arg = build_port_ranges_excluding(start_p, 65535, actual_ex)
|
|
127
|
+
total_p = (65535 - start_p + 1)
|
|
128
|
+
remaining = max(0, total_p - len(actual_ex))
|
|
129
|
+
return filtered_arg, remaining, len(actual_ex), actual_ex
|
|
130
|
+
|
|
131
|
+
target_ports = parse_port_spec_to_set(ports_spec)
|
|
132
|
+
initial_count = len(target_ports)
|
|
133
|
+
actual_ex = target_ports & excluded_set
|
|
134
|
+
remaining_ports = target_ports - excluded_set
|
|
135
|
+
excluded_count = len(actual_ex)
|
|
136
|
+
|
|
137
|
+
if not remaining_ports:
|
|
138
|
+
return None, 0, excluded_count, actual_ex
|
|
139
|
+
|
|
140
|
+
# If top-ports was asked without any exclusions
|
|
141
|
+
if ports_spec.strip().startswith("--top-ports") and excluded_count == 0:
|
|
142
|
+
return ports_spec.strip(), len(remaining_ports), 0, set()
|
|
143
|
+
|
|
144
|
+
sorted_rem = sorted(remaining_ports)
|
|
145
|
+
ranges = []
|
|
146
|
+
r_start = sorted_rem[0]
|
|
147
|
+
r_prev = sorted_rem[0]
|
|
148
|
+
|
|
149
|
+
for p in sorted_rem[1:]:
|
|
150
|
+
if p == r_prev + 1:
|
|
151
|
+
r_prev = p
|
|
152
|
+
else:
|
|
153
|
+
if r_start == r_prev:
|
|
154
|
+
ranges.append(str(r_start))
|
|
155
|
+
else:
|
|
156
|
+
ranges.append(f"{r_start}-{r_prev}")
|
|
157
|
+
r_start = p
|
|
158
|
+
r_prev = p
|
|
159
|
+
if r_start == r_prev:
|
|
160
|
+
ranges.append(str(r_start))
|
|
161
|
+
else:
|
|
162
|
+
ranges.append(f"{r_start}-{r_prev}")
|
|
163
|
+
|
|
164
|
+
return ",".join(ranges), len(remaining_ports), excluded_count, actual_ex
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def calculate_dynamic_timeout(
|
|
168
|
+
ports_spec: Optional[str],
|
|
169
|
+
rate: int = 1000,
|
|
170
|
+
num_targets: int = 1,
|
|
171
|
+
min_timeout: float = 45.0,
|
|
172
|
+
) -> float:
|
|
173
|
+
"""Compute adaptive execution timeout based on total port count, target count, and scan rate.
|
|
174
|
+
|
|
175
|
+
Formula:
|
|
176
|
+
Total Probes = len(ports) * num_targets
|
|
177
|
+
Transmission Time = Total Probes / max(rate, 10)
|
|
178
|
+
Safety & Banner Buffer = 15.0s base + (num_targets * 2.0s)
|
|
179
|
+
Timeout = max(min_timeout, (Transmission Time * 1.6) + Safety Buffer)
|
|
180
|
+
"""
|
|
181
|
+
clean_rate = max(10, min(rate, 25000))
|
|
182
|
+
targets_count = max(1, num_targets)
|
|
183
|
+
|
|
184
|
+
if not ports_spec or not ports_spec.strip():
|
|
185
|
+
port_count = 100
|
|
186
|
+
else:
|
|
187
|
+
spec = ports_spec.strip()
|
|
188
|
+
if spec.startswith("-p"):
|
|
189
|
+
spec = spec[2:].strip()
|
|
190
|
+
if spec in ("-", "all", "0-65535", "1-65535", "-p0-65535", "-p1-65535"):
|
|
191
|
+
port_count = 65536
|
|
192
|
+
elif spec.startswith("--top-ports"):
|
|
193
|
+
parts = spec.split()
|
|
194
|
+
port_count = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 100
|
|
195
|
+
else:
|
|
196
|
+
try:
|
|
197
|
+
port_count = len(parse_port_spec_to_set(ports_spec))
|
|
198
|
+
except Exception:
|
|
199
|
+
port_count = 100
|
|
200
|
+
|
|
201
|
+
total_probes = max(1, port_count) * targets_count
|
|
202
|
+
transmission_time = total_probes / clean_rate
|
|
203
|
+
safety_buffer = 15.0 + (targets_count * 2.0)
|
|
204
|
+
|
|
205
|
+
computed_timeout = (transmission_time * 1.6) + safety_buffer
|
|
206
|
+
return round(max(min_timeout, computed_timeout), 1)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class MasscanRunner:
|
|
210
|
+
"""Async Masscan execution engine for targeted active port scanning."""
|
|
211
|
+
|
|
212
|
+
def __init__(self, binary_path: Optional[str] = None):
|
|
213
|
+
self.binary_path = binary_path or shutil.which("masscan") or "/usr/bin/masscan"
|
|
214
|
+
|
|
215
|
+
def is_available(self) -> bool:
|
|
216
|
+
"""Check if masscan executable exists and is accessible."""
|
|
217
|
+
if not self.binary_path:
|
|
218
|
+
return False
|
|
219
|
+
p = Path(self.binary_path)
|
|
220
|
+
return p.exists() and os.access(str(p), os.X_OK)
|
|
221
|
+
|
|
222
|
+
def check_permissions(self) -> Dict[str, Any]:
|
|
223
|
+
"""Verify binary availability and execution permissions."""
|
|
224
|
+
available = self.is_available()
|
|
225
|
+
is_root = os.geteuid() == 0 if hasattr(os, "geteuid") else False
|
|
226
|
+
return {
|
|
227
|
+
"available": available,
|
|
228
|
+
"binary_path": self.binary_path if available else None,
|
|
229
|
+
"is_root": is_root,
|
|
230
|
+
"can_run": available,
|
|
231
|
+
"message": (
|
|
232
|
+
"Masscan ready"
|
|
233
|
+
if available
|
|
234
|
+
else "Masscan binary not found on system (install with: apt-get install masscan)"
|
|
235
|
+
),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async def scan_target(
|
|
239
|
+
self,
|
|
240
|
+
target_ip: Optional[str] = None,
|
|
241
|
+
ports: Optional[str] = None,
|
|
242
|
+
rate: int = 1000,
|
|
243
|
+
disable_ping: bool = True,
|
|
244
|
+
banners: bool = True,
|
|
245
|
+
custom_flags: Optional[str] = None,
|
|
246
|
+
timeout: Optional[float] = None,
|
|
247
|
+
target: Optional[str] = None,
|
|
248
|
+
num_targets: int = 1,
|
|
249
|
+
) -> Dict[str, Any]:
|
|
250
|
+
"""Execute masscan against a specific target IP and return parsed findings."""
|
|
251
|
+
target_ip = target_ip or target or ""
|
|
252
|
+
if not self.is_available():
|
|
253
|
+
return {
|
|
254
|
+
"success": False,
|
|
255
|
+
"target": target_ip,
|
|
256
|
+
"error": "Masscan executable not found on host system",
|
|
257
|
+
"ports": [],
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
target_ip = target_ip.strip()
|
|
261
|
+
temp_out = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
|
|
262
|
+
temp_out_path = temp_out.name
|
|
263
|
+
temp_out.close()
|
|
264
|
+
|
|
265
|
+
# Compute dynamic timeout based on workload if not explicitly passed
|
|
266
|
+
min_needed_timeout = calculate_dynamic_timeout(ports, rate=rate, num_targets=num_targets)
|
|
267
|
+
if timeout is None or timeout <= 0:
|
|
268
|
+
effective_timeout = min_needed_timeout
|
|
269
|
+
else:
|
|
270
|
+
effective_timeout = max(timeout, min_needed_timeout)
|
|
271
|
+
|
|
272
|
+
cmd = [self.binary_path, target_ip]
|
|
273
|
+
|
|
274
|
+
# Port specification
|
|
275
|
+
if ports:
|
|
276
|
+
p_clean = ports.strip()
|
|
277
|
+
if p_clean in ("-p-", "-", "all", "0-65535", "1-65535", "-p0-65535", "-p1-65535"):
|
|
278
|
+
cmd.extend(["-p", "0-65535"])
|
|
279
|
+
elif p_clean.startswith("-p"):
|
|
280
|
+
val = p_clean[2:].strip()
|
|
281
|
+
if val in ("-", "all", "0-65535", "1-65535"):
|
|
282
|
+
cmd.extend(["-p", "0-65535"])
|
|
283
|
+
else:
|
|
284
|
+
cmd.extend(["-p", val])
|
|
285
|
+
elif p_clean.startswith("--top-ports"):
|
|
286
|
+
parts = p_clean.split()
|
|
287
|
+
cmd.extend(["--top-ports", parts[1] if len(parts) > 1 else "100"])
|
|
288
|
+
else:
|
|
289
|
+
cmd.extend(["-p", p_clean])
|
|
290
|
+
else:
|
|
291
|
+
cmd.extend(["--top-ports", "100"])
|
|
292
|
+
|
|
293
|
+
# Rate control
|
|
294
|
+
cmd.extend(["--rate", str(max(10, min(rate, 25000)))])
|
|
295
|
+
|
|
296
|
+
# Disable ping (-Pn)
|
|
297
|
+
if disable_ping:
|
|
298
|
+
cmd.append("-Pn")
|
|
299
|
+
|
|
300
|
+
# Banner grabbing & HTTP User-Agent evasion
|
|
301
|
+
if banners:
|
|
302
|
+
cmd.append("--banners")
|
|
303
|
+
cmd.extend(["--http-user-agent", DEFAULT_HTTP_USER_AGENT])
|
|
304
|
+
|
|
305
|
+
# Custom flags
|
|
306
|
+
if custom_flags:
|
|
307
|
+
import shlex
|
|
308
|
+
try:
|
|
309
|
+
extra_args = shlex.split(custom_flags.strip())
|
|
310
|
+
cmd.extend(extra_args)
|
|
311
|
+
except Exception as e:
|
|
312
|
+
logger.warning(f"Error parsing custom masscan flags: {e}")
|
|
313
|
+
|
|
314
|
+
# JSON output to temp file
|
|
315
|
+
cmd.extend(["-oJ", temp_out_path])
|
|
316
|
+
|
|
317
|
+
logger.info(f"Executing masscan (timeout={effective_timeout}s): {' '.join(cmd)}")
|
|
318
|
+
|
|
319
|
+
proc = None
|
|
320
|
+
try:
|
|
321
|
+
proc = await asyncio.create_subprocess_exec(
|
|
322
|
+
*cmd,
|
|
323
|
+
stdout=asyncio.subprocess.PIPE,
|
|
324
|
+
stderr=asyncio.subprocess.PIPE,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
|
329
|
+
proc.communicate(), timeout=effective_timeout
|
|
330
|
+
)
|
|
331
|
+
except asyncio.TimeoutError:
|
|
332
|
+
logger.warning(
|
|
333
|
+
f"Masscan timed out after {effective_timeout}s on {target_ip}. Gracefully flushing with SIGINT..."
|
|
334
|
+
)
|
|
335
|
+
if proc:
|
|
336
|
+
try:
|
|
337
|
+
import signal
|
|
338
|
+
proc.send_signal(signal.SIGINT)
|
|
339
|
+
try:
|
|
340
|
+
await asyncio.wait_for(proc.wait(), timeout=3.0)
|
|
341
|
+
except (asyncio.TimeoutError, Exception):
|
|
342
|
+
proc.kill()
|
|
343
|
+
except Exception:
|
|
344
|
+
try:
|
|
345
|
+
proc.kill()
|
|
346
|
+
except Exception:
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
# Parse all ports discovered by masscan before timeout occurred
|
|
350
|
+
discovered_ports = self._parse_json_file(temp_out_path, target_ip)
|
|
351
|
+
return {
|
|
352
|
+
"success": len(discovered_ports) > 0,
|
|
353
|
+
"target": target_ip,
|
|
354
|
+
"error": f"Masscan execution timed out after {effective_timeout} seconds",
|
|
355
|
+
"ports": discovered_ports,
|
|
356
|
+
"open_ports": discovered_ports,
|
|
357
|
+
"count": len(discovered_ports),
|
|
358
|
+
"timeout_seconds": effective_timeout,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
stderr_text = stderr_bytes.decode("utf-8", errors="replace")
|
|
362
|
+
|
|
363
|
+
# Check exit code and parse findings
|
|
364
|
+
open_ports = self._parse_json_file(temp_out_path, target_ip)
|
|
365
|
+
|
|
366
|
+
# Check if there was a permission error
|
|
367
|
+
if "requires root privileges" in stderr_text or "permission denied" in stderr_text.lower():
|
|
368
|
+
return {
|
|
369
|
+
"success": False,
|
|
370
|
+
"target": target_ip,
|
|
371
|
+
"error": "Masscan requires root or CAP_NET_RAW privileges to run raw packet scans",
|
|
372
|
+
"ports": [],
|
|
373
|
+
"open_ports": [],
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
"success": True,
|
|
378
|
+
"target": target_ip,
|
|
379
|
+
"ports": open_ports,
|
|
380
|
+
"open_ports": open_ports,
|
|
381
|
+
"count": len(open_ports),
|
|
382
|
+
"command": " ".join(cmd),
|
|
383
|
+
"timeout_seconds": effective_timeout,
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
except Exception as exc:
|
|
387
|
+
logger.error(f"Error executing masscan on {target_ip}: {exc}")
|
|
388
|
+
discovered_ports = self._parse_json_file(temp_out_path, target_ip)
|
|
389
|
+
return {
|
|
390
|
+
"success": len(discovered_ports) > 0,
|
|
391
|
+
"target": target_ip,
|
|
392
|
+
"error": str(exc),
|
|
393
|
+
"ports": discovered_ports,
|
|
394
|
+
"open_ports": discovered_ports,
|
|
395
|
+
"count": len(discovered_ports),
|
|
396
|
+
}
|
|
397
|
+
finally:
|
|
398
|
+
# Clean up temp file
|
|
399
|
+
try:
|
|
400
|
+
if os.path.exists(temp_out_path):
|
|
401
|
+
os.remove(temp_out_path)
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
def _parse_json_file(self, filepath: str, default_ip: str) -> List[Dict[str, Any]]:
|
|
406
|
+
"""Parse masscan JSON output safely, consolidating port records and extracting banners."""
|
|
407
|
+
if not os.path.exists(filepath):
|
|
408
|
+
return []
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
|
412
|
+
content = f.read().strip()
|
|
413
|
+
|
|
414
|
+
if not content:
|
|
415
|
+
return []
|
|
416
|
+
|
|
417
|
+
# Handle Masscan JSON quirks (trailing commas before closing bracket, mid-stream EOF)
|
|
418
|
+
import re
|
|
419
|
+
content = re.sub(r',\s*([\]\}])', r'\1', content)
|
|
420
|
+
if not content.endswith("]") and not content.endswith("}"):
|
|
421
|
+
content += "\n]"
|
|
422
|
+
if not content.startswith("[") and not content.startswith("{"):
|
|
423
|
+
content = "[" + content
|
|
424
|
+
|
|
425
|
+
data = json.loads(content)
|
|
426
|
+
# Use dictionary keyed by (ip, port, proto) to merge duplicate masscan records (status + banners)
|
|
427
|
+
merged_ports: Dict[tuple, Dict[str, Any]] = {}
|
|
428
|
+
|
|
429
|
+
for item in data:
|
|
430
|
+
ip = item.get("ip", default_ip)
|
|
431
|
+
for p in item.get("ports", []):
|
|
432
|
+
port_num = p.get("port")
|
|
433
|
+
if port_num is None:
|
|
434
|
+
continue
|
|
435
|
+
|
|
436
|
+
proto = p.get("proto", "tcp").lower()
|
|
437
|
+
status = p.get("status", "open")
|
|
438
|
+
ttl = p.get("ttl")
|
|
439
|
+
|
|
440
|
+
service_info = p.get("service", {})
|
|
441
|
+
service_name = service_info.get("name") if isinstance(service_info, dict) else None
|
|
442
|
+
banner = service_info.get("banner") if isinstance(service_info, dict) else None
|
|
443
|
+
|
|
444
|
+
key = (ip, int(port_num), proto)
|
|
445
|
+
if key not in merged_ports:
|
|
446
|
+
inferred_name = service_name or self._infer_service_name(int(port_num))
|
|
447
|
+
merged_ports[key] = {
|
|
448
|
+
"ip": ip,
|
|
449
|
+
"port": int(port_num),
|
|
450
|
+
"protocol": proto,
|
|
451
|
+
"status": status,
|
|
452
|
+
"ttl": ttl,
|
|
453
|
+
"service_name": inferred_name,
|
|
454
|
+
"product": "",
|
|
455
|
+
"version": "",
|
|
456
|
+
"banner": banner or "",
|
|
457
|
+
"ssl": (int(port_num) == 443 or "https" in (inferred_name or "").lower() or "ssl" in (inferred_name or "").lower()),
|
|
458
|
+
"source": "Masscan",
|
|
459
|
+
}
|
|
460
|
+
else:
|
|
461
|
+
entry = merged_ports[key]
|
|
462
|
+
if status:
|
|
463
|
+
entry["status"] = status
|
|
464
|
+
if ttl:
|
|
465
|
+
entry["ttl"] = ttl
|
|
466
|
+
if service_name and (not entry["service_name"] or entry["service_name"].startswith("service-")):
|
|
467
|
+
entry["service_name"] = service_name
|
|
468
|
+
if banner and len(banner) > len(entry.get("banner", "")):
|
|
469
|
+
entry["banner"] = banner
|
|
470
|
+
if int(port_num) == 443 or "https" in (entry["service_name"] or "").lower() or "ssl" in (entry["service_name"] or "").lower() or (service_name and "ssl" in service_name.lower()):
|
|
471
|
+
entry["ssl"] = True
|
|
472
|
+
|
|
473
|
+
# Extract product and version from banner if available
|
|
474
|
+
results: List[Dict[str, Any]] = []
|
|
475
|
+
for entry in merged_ports.values():
|
|
476
|
+
banner_str = entry.get("banner", "")
|
|
477
|
+
if banner_str:
|
|
478
|
+
prod, ver = self._extract_product_version(banner_str, entry["port"])
|
|
479
|
+
entry["product"] = prod
|
|
480
|
+
entry["version"] = ver
|
|
481
|
+
results.append(entry)
|
|
482
|
+
|
|
483
|
+
# Sort by port number
|
|
484
|
+
results.sort(key=lambda x: x["port"])
|
|
485
|
+
return results
|
|
486
|
+
|
|
487
|
+
except Exception as e:
|
|
488
|
+
logger.warning(f"Error parsing masscan JSON output: {e}")
|
|
489
|
+
return []
|
|
490
|
+
|
|
491
|
+
@staticmethod
|
|
492
|
+
def _extract_product_version(banner: str, port: int) -> tuple[str, str]:
|
|
493
|
+
"""Extract product name and version string from banner."""
|
|
494
|
+
import re
|
|
495
|
+
if not banner:
|
|
496
|
+
return "", ""
|
|
497
|
+
|
|
498
|
+
banner_clean = banner.strip()
|
|
499
|
+
|
|
500
|
+
# 1. HTTP Server Header: Server: <name>/<version>
|
|
501
|
+
server_m = re.search(r"Server:\s*([^\r\n]+)", banner_clean, re.IGNORECASE)
|
|
502
|
+
if server_m:
|
|
503
|
+
server_val = server_m.group(1).strip()
|
|
504
|
+
parts = server_val.split("/", 1)
|
|
505
|
+
prod = parts[0].strip()
|
|
506
|
+
ver = ""
|
|
507
|
+
if len(parts) > 1:
|
|
508
|
+
ver = parts[1].split()[0].strip()
|
|
509
|
+
return prod, ver
|
|
510
|
+
|
|
511
|
+
# 2. SSH Banner: SSH-2.0-OpenSSH_8.9p1 Ubuntu
|
|
512
|
+
if banner_clean.startswith("SSH-"):
|
|
513
|
+
parts = banner_clean.split("-", 2)
|
|
514
|
+
if len(parts) >= 3:
|
|
515
|
+
prod_ver = parts[2].split()[0].strip()
|
|
516
|
+
if "_" in prod_ver:
|
|
517
|
+
p, v = prod_ver.split("_", 1)
|
|
518
|
+
return p, v
|
|
519
|
+
return prod_ver, ""
|
|
520
|
+
|
|
521
|
+
# 3. Simple banner like 'cloudflare' or 'nginx'
|
|
522
|
+
first_line = banner_clean.splitlines()[0].strip() if banner_clean else ""
|
|
523
|
+
if len(first_line) < 40 and not first_line.startswith("HTTP/"):
|
|
524
|
+
parts = first_line.split("/", 1)
|
|
525
|
+
if len(parts) == 2:
|
|
526
|
+
return parts[0].strip(), parts[1].split()[0].strip()
|
|
527
|
+
return first_line, ""
|
|
528
|
+
|
|
529
|
+
return "", ""
|
|
530
|
+
|
|
531
|
+
@staticmethod
|
|
532
|
+
def _infer_service_name(port: int) -> str:
|
|
533
|
+
"""Infer common service name from port number."""
|
|
534
|
+
common = {
|
|
535
|
+
21: "ftp",
|
|
536
|
+
22: "ssh",
|
|
537
|
+
23: "telnet",
|
|
538
|
+
25: "smtp",
|
|
539
|
+
53: "dns",
|
|
540
|
+
80: "http",
|
|
541
|
+
110: "pop3",
|
|
542
|
+
143: "imap",
|
|
543
|
+
443: "https",
|
|
544
|
+
445: "microsoft-ds",
|
|
545
|
+
993: "imaps",
|
|
546
|
+
995: "pop3s",
|
|
547
|
+
1433: "mssql",
|
|
548
|
+
1521: "oracle",
|
|
549
|
+
3306: "mysql",
|
|
550
|
+
3389: "rdp",
|
|
551
|
+
5432: "postgresql",
|
|
552
|
+
5900: "vnc",
|
|
553
|
+
6379: "redis",
|
|
554
|
+
8000: "http-alt",
|
|
555
|
+
8080: "http-proxy",
|
|
556
|
+
8443: "https-alt",
|
|
557
|
+
8888: "http-alt",
|
|
558
|
+
9200: "elasticsearch",
|
|
559
|
+
27017: "mongodb",
|
|
560
|
+
}
|
|
561
|
+
return common.get(port, f"service-{port}")
|