netscraper 0.1.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.
- netscraper/__init__.py +9 -0
- netscraper/cli.py +635 -0
- netscraper/core/__init__.py +0 -0
- netscraper/core/banner.py +369 -0
- netscraper/core/cert_chain_parser.py +51 -0
- netscraper/core/cert_chain_rules.py +97 -0
- netscraper/core/cert_chain_runner.py +73 -0
- netscraper/core/concurrency.py +98 -0
- netscraper/core/cors_parser.py +45 -0
- netscraper/core/cors_rules.py +71 -0
- netscraper/core/cors_runner.py +100 -0
- netscraper/core/dirsearch_parser.py +48 -0
- netscraper/core/dirsearch_rules.py +39 -0
- netscraper/core/dirsearch_runner.py +109 -0
- netscraper/core/dns_parser.py +34 -0
- netscraper/core/dns_rules.py +57 -0
- netscraper/core/dns_runner.py +68 -0
- netscraper/core/excel_report.py +84 -0
- netscraper/core/families.py +467 -0
- netscraper/core/ftp_parser.py +28 -0
- netscraper/core/ftp_rules.py +56 -0
- netscraper/core/ftp_runner.py +63 -0
- netscraper/core/http_parser.py +59 -0
- netscraper/core/http_rules.py +81 -0
- netscraper/core/http_runner.py +118 -0
- netscraper/core/ldap_parser.py +53 -0
- netscraper/core/ldap_rules.py +109 -0
- netscraper/core/ldap_runner.py +100 -0
- netscraper/core/mssql_parser.py +26 -0
- netscraper/core/mssql_rules.py +35 -0
- netscraper/core/mssql_runner.py +70 -0
- netscraper/core/poc_renderer.py +399 -0
- netscraper/core/port_gate.py +79 -0
- netscraper/core/rpc_parser.py +59 -0
- netscraper/core/rpc_rules.py +86 -0
- netscraper/core/rpc_runner.py +82 -0
- netscraper/core/rules.py +234 -0
- netscraper/core/scan_worker.py +182 -0
- netscraper/core/smb_parser.py +69 -0
- netscraper/core/smb_rules.py +121 -0
- netscraper/core/smb_runner.py +123 -0
- netscraper/core/snmp_parser.py +23 -0
- netscraper/core/snmp_pure_probe.py +201 -0
- netscraper/core/snmp_rules.py +38 -0
- netscraper/core/snmp_runner.py +95 -0
- netscraper/core/ssh_parser.py +50 -0
- netscraper/core/ssh_rules.py +76 -0
- netscraper/core/ssh_runner.py +91 -0
- netscraper/core/sslscan_parser.py +140 -0
- netscraper/core/sslscan_runner.py +145 -0
- netscraper/core/targets.py +96 -0
- netscraper/core/terminal_capture.py +112 -0
- netscraper/core/theme.py +97 -0
- netscraper/wordlists/snmp_communities.txt +16 -0
- netscraper-0.1.0.dist-info/METADATA +695 -0
- netscraper-0.1.0.dist-info/RECORD +60 -0
- netscraper-0.1.0.dist-info/WHEEL +5 -0
- netscraper-0.1.0.dist-info/entry_points.txt +2 -0
- netscraper-0.1.0.dist-info/licenses/LICENSE +21 -0
- netscraper-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Startup banner + interactive mode menu for the NetScraper CLI.
|
|
3
|
+
|
|
4
|
+
Adapted from a user-supplied NetScraper-style banner (figlet "ANSI Shadow"
|
|
5
|
+
title, green->cyan gradient, tapered "signal bar" accents). Every line is
|
|
6
|
+
centered on the *actual* terminal width (not just centered within its own
|
|
7
|
+
box) so it looks right regardless of window size, and every single line
|
|
8
|
+
goes through netscraper.core.theme so nothing prints in the terminal's default color.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from netscraper.core.theme import c, title, accent, warn, ok, prompt, muted, RESET
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ReturnToMainMenu(Exception):
|
|
21
|
+
"""
|
|
22
|
+
Raised anywhere in the interactive flow (a sub-prompt, or mid-scan) to
|
|
23
|
+
bounce back to the top-level Individual/Bulk/Exit menu instead of
|
|
24
|
+
crashing or quitting the whole process. The only things that actually
|
|
25
|
+
end the process are choosing "Exit" at that top-level menu, or Ctrl-C
|
|
26
|
+
*at* that top-level menu -- everywhere else (entering a target, picking
|
|
27
|
+
check modes, or a scan that's actively running), Ctrl-C means "go back",
|
|
28
|
+
not "quit".
|
|
29
|
+
"""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------- #
|
|
33
|
+
# Big block title -- figlet "ANSI Shadow" font, pre-rendered so this
|
|
34
|
+
# file has zero external dependencies. One color per row gives a
|
|
35
|
+
# green -> cyan "matrix scan" gradient.
|
|
36
|
+
# ---------------------------------------------------------------- #
|
|
37
|
+
TITLE = [
|
|
38
|
+
("███╗ ██╗███████╗████████╗███████╗ ██████╗██████╗ █████╗ ██████╗ ███████╗██████╗ ", "br_green"),
|
|
39
|
+
("████╗ ██║██╔════╝╚══██╔══╝██╔════╝██╔════╝██╔══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗", "green"),
|
|
40
|
+
("██╔██╗ ██║█████╗ ██║ ███████╗██║ ██████╔╝███████║██████╔╝█████╗ ██████╔╝", "green"),
|
|
41
|
+
("██║╚██╗██║██╔══╝ ██║ ╚════██║██║ ██╔══██╗██╔══██║██╔═══╝ ██╔══╝ ██╔══██╗", "cyan"),
|
|
42
|
+
("██║ ╚████║███████╗ ██║ ███████║╚██████╗██║ ██║██║ ██║██║ ███████╗██║ ██║", "cyan"),
|
|
43
|
+
("╚═╝ ╚═══╝╚══════╝ ╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝", "br_cyan"),
|
|
44
|
+
]
|
|
45
|
+
TAGLINE = "Automated Network Reconnaissance & Vulnerability Scanning Framework"
|
|
46
|
+
WARNING = "For authorized penetration testing & research use only."
|
|
47
|
+
CREDIT = "Made by Kav1n"
|
|
48
|
+
|
|
49
|
+
# Ramp used to build the tapered "signal" bars that bookend the title.
|
|
50
|
+
_RAMP = [" ", "░", "▒", "▓", "█"]
|
|
51
|
+
_RAMP_COLOR = {1: "green", 2: "br_green", 3: "cyan", 4: "br_cyan"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _signal_bar(width: int, taper: float = 0.6):
|
|
55
|
+
"""A glow-style bar, brightest at center, fading to green at the
|
|
56
|
+
edges -- reads as a 'scan pulse' rather than any literal object."""
|
|
57
|
+
center = (width - 1) / 2 or 1
|
|
58
|
+
row = []
|
|
59
|
+
for i in range(width):
|
|
60
|
+
d = abs(i - center) / center
|
|
61
|
+
level = max(0.0, 1 - d / taper)
|
|
62
|
+
idx = min(4, int(level * 5))
|
|
63
|
+
row.append((_RAMP[idx], _RAMP_COLOR.get(idx)))
|
|
64
|
+
return row
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _box_width() -> int:
|
|
68
|
+
return max(max(len(text) for text, _ in TITLE), len(TAGLINE) + 4) + 4
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def clear_screen() -> None:
|
|
72
|
+
os.system("cls" if os.name == "nt" else "clear")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _term_width() -> int:
|
|
76
|
+
try:
|
|
77
|
+
return shutil.get_terminal_size(fallback=(120, 40)).columns
|
|
78
|
+
except OSError:
|
|
79
|
+
return 120
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def print_banner() -> None:
|
|
83
|
+
box_width = _box_width()
|
|
84
|
+
bar_width = box_width - 12
|
|
85
|
+
term_width = _term_width()
|
|
86
|
+
# Centers the WHOLE box on the real terminal width -- not just text
|
|
87
|
+
# centered inside a box that itself sits flush-left.
|
|
88
|
+
margin = max(0, (term_width - (box_width + 2)) // 2)
|
|
89
|
+
pad = " " * margin
|
|
90
|
+
|
|
91
|
+
top = "╔" + "═" * box_width + "╗"
|
|
92
|
+
bottom = "╚" + "═" * box_width + "╝"
|
|
93
|
+
|
|
94
|
+
def emit(line: str) -> None:
|
|
95
|
+
print(pad + line)
|
|
96
|
+
|
|
97
|
+
def centered(text: str) -> tuple[int, int]:
|
|
98
|
+
gap = box_width - len(text)
|
|
99
|
+
left = gap // 2
|
|
100
|
+
return left, gap - left
|
|
101
|
+
|
|
102
|
+
def emit_plain() -> None:
|
|
103
|
+
emit("║" + " " * box_width + "║")
|
|
104
|
+
|
|
105
|
+
def emit_solid(text: str, color: str) -> None:
|
|
106
|
+
left, right = centered(text)
|
|
107
|
+
emit("║" + " " * left + c(text, color) + " " * right + "║")
|
|
108
|
+
|
|
109
|
+
def emit_bar() -> None:
|
|
110
|
+
cells = _signal_bar(bar_width)
|
|
111
|
+
plain = "".join(ch for ch, _ in cells)
|
|
112
|
+
left, right = centered(plain)
|
|
113
|
+
colored = "".join(c(ch, col) if col else " " for ch, col in cells)
|
|
114
|
+
emit("║" + " " * left + colored + " " * right + "║")
|
|
115
|
+
|
|
116
|
+
print()
|
|
117
|
+
emit(c(top, "border"))
|
|
118
|
+
emit_plain()
|
|
119
|
+
emit_bar()
|
|
120
|
+
emit_plain()
|
|
121
|
+
for text, color in TITLE:
|
|
122
|
+
emit_solid(text, color)
|
|
123
|
+
emit_plain()
|
|
124
|
+
emit_bar()
|
|
125
|
+
emit_plain()
|
|
126
|
+
emit_solid(TAGLINE, "white")
|
|
127
|
+
emit_plain()
|
|
128
|
+
emit_solid(WARNING, "yellow")
|
|
129
|
+
emit_plain()
|
|
130
|
+
emit(c(bottom, "border"))
|
|
131
|
+
print(_centered_line(muted(CREDIT)))
|
|
132
|
+
print()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------------------------------------------------------------- #
|
|
136
|
+
# Interactive mode menu
|
|
137
|
+
# ---------------------------------------------------------------- #
|
|
138
|
+
|
|
139
|
+
def _centered_line(text: str) -> str:
|
|
140
|
+
term_width = _term_width()
|
|
141
|
+
visible_len = len(text)
|
|
142
|
+
margin = max(0, (term_width - visible_len) // 2)
|
|
143
|
+
return " " * margin + text
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _visible_len(text: str) -> int:
|
|
150
|
+
"""Length of `text` with ANSI color codes stripped out -- the color
|
|
151
|
+
codes themselves take up zero screen columns, so counting them (as
|
|
152
|
+
plain len() does) undercounts how much room is actually left, and by
|
|
153
|
+
a different amount on every line depending on how many colored spans
|
|
154
|
+
it has."""
|
|
155
|
+
return len(_ANSI_RE.sub("", text))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _emit_numbered_menu(lines: list[str]) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Prints a numbered list of menu `lines` (each may already contain ANSI
|
|
161
|
+
color codes) as ONE block sharing a single left margin, computed once
|
|
162
|
+
from the widest line's true visible width.
|
|
163
|
+
|
|
164
|
+
This is deliberately different from centering each line independently
|
|
165
|
+
(what _centered_line does, fine for a single standalone line): a
|
|
166
|
+
numbered list centered line-by-line staggers its "[1]"/"[2]"/... column
|
|
167
|
+
raggedly the moment descriptions differ in length, or in how many
|
|
168
|
+
colored spans they carry -- exactly the "why don't 1,2,3,4 line up"
|
|
169
|
+
problem this exists to fix. One shared margin keeps every option's
|
|
170
|
+
bracket in the same screen column, uniformly, regardless of how long
|
|
171
|
+
its description is.
|
|
172
|
+
"""
|
|
173
|
+
term_width = _term_width()
|
|
174
|
+
max_visible = max((_visible_len(line) for line in lines), default=0)
|
|
175
|
+
margin = max(0, (term_width - max_visible) // 2)
|
|
176
|
+
pad = " " * margin
|
|
177
|
+
for line in lines:
|
|
178
|
+
print(pad + line)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def prompt_mode() -> tuple[str, str]:
|
|
182
|
+
"""
|
|
183
|
+
Shows the Individual / Bulk / Exit menu and collects the target source.
|
|
184
|
+
Returns (mode, value):
|
|
185
|
+
("target", "<ip/hostname/cidr>") -- Individual Mode
|
|
186
|
+
("targets", "<path/to/file.txt>") -- Bulk Scanning
|
|
187
|
+
|
|
188
|
+
This is the one true top-level menu: choosing Exit here, or hitting
|
|
189
|
+
Ctrl-C/EOF *at this menu itself*, ends the process (SystemExit).
|
|
190
|
+
Anywhere past this point (the target/file sub-prompt), Ctrl-C/EOF or
|
|
191
|
+
empty input instead raises ReturnToMainMenu so the caller can loop back
|
|
192
|
+
here and show this same menu again, rather than quitting outright.
|
|
193
|
+
"""
|
|
194
|
+
mode_entries = [
|
|
195
|
+
("Individual Mode", "scan a single IP / hostname / CIDR subnet"),
|
|
196
|
+
("Bulk Scanning", "scan a list of targets from a file"),
|
|
197
|
+
("Exit", "quit the tool"),
|
|
198
|
+
]
|
|
199
|
+
label_width = max(len(label) for label, _ in mode_entries)
|
|
200
|
+
menu_lines = [
|
|
201
|
+
accent(f" [{i}]") + " " + c(label.ljust(label_width), "white") + muted(" -- " + desc)
|
|
202
|
+
for i, (label, desc) in enumerate(mode_entries, start=1)
|
|
203
|
+
]
|
|
204
|
+
_emit_numbered_menu(menu_lines)
|
|
205
|
+
print()
|
|
206
|
+
|
|
207
|
+
choice = None
|
|
208
|
+
while choice not in ("1", "2", "3"):
|
|
209
|
+
try:
|
|
210
|
+
choice = input(_centered_line(prompt(" Select mode (1/2/3): "))).strip()
|
|
211
|
+
except (EOFError, KeyboardInterrupt):
|
|
212
|
+
print()
|
|
213
|
+
print(_centered_line(ok("Goodbye.")))
|
|
214
|
+
raise SystemExit(0)
|
|
215
|
+
if choice not in ("1", "2", "3"):
|
|
216
|
+
print(_centered_line(warn(f"Invalid choice '{choice}'") + muted(" -- enter 1, 2, or 3.")))
|
|
217
|
+
|
|
218
|
+
if choice == "3":
|
|
219
|
+
print(_centered_line(ok("Goodbye.")))
|
|
220
|
+
raise SystemExit(0)
|
|
221
|
+
|
|
222
|
+
if choice == "1":
|
|
223
|
+
try:
|
|
224
|
+
target = input(_centered_line(prompt(" Enter target (IP / hostname / CIDR subnet): "))).strip()
|
|
225
|
+
except (EOFError, KeyboardInterrupt):
|
|
226
|
+
print()
|
|
227
|
+
raise ReturnToMainMenu()
|
|
228
|
+
if not target:
|
|
229
|
+
print(_centered_line(warn("No target entered -- returning to the main menu.")))
|
|
230
|
+
raise ReturnToMainMenu()
|
|
231
|
+
print()
|
|
232
|
+
return "target", target
|
|
233
|
+
|
|
234
|
+
# choice == "2"
|
|
235
|
+
while True:
|
|
236
|
+
try:
|
|
237
|
+
path = input(_centered_line(prompt(" Enter path to targets file: "))).strip()
|
|
238
|
+
except (EOFError, KeyboardInterrupt):
|
|
239
|
+
print()
|
|
240
|
+
raise ReturnToMainMenu()
|
|
241
|
+
if not path:
|
|
242
|
+
print(_centered_line(warn("No file entered -- returning to the main menu.")))
|
|
243
|
+
raise ReturnToMainMenu()
|
|
244
|
+
if os.path.isfile(path):
|
|
245
|
+
print()
|
|
246
|
+
return "targets", path
|
|
247
|
+
print(_centered_line(warn(f"File not found: {path}") + muted(" -- try again, or Ctrl-C for the main menu")))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def prompt_scan_modes(options: list[tuple[str, str]], allow_back: bool = False) -> list[str]:
|
|
251
|
+
"""
|
|
252
|
+
Shows a numbered menu of check modes (e.g. TLS, SSH, SMB, ...) plus a
|
|
253
|
+
final "All checks" entry, and lets the user pick one or more -- by
|
|
254
|
+
number, comma-separated (e.g. "1,3,7"), or by typing "all".
|
|
255
|
+
|
|
256
|
+
options: [(mode_name, description), ...] in display order, where
|
|
257
|
+
description is what's actually printed for that line (callers build
|
|
258
|
+
this with the live port-scan counts baked in, e.g.
|
|
259
|
+
"TLS -- 3/11 host(s) with the required port open").
|
|
260
|
+
|
|
261
|
+
allow_back=True adds one more entry after "All checks": "Back to main
|
|
262
|
+
menu (change target / subnet)". Picking it (by number, or typing
|
|
263
|
+
"back"/"b") raises ReturnToMainMenu, exactly like Ctrl-C does here --
|
|
264
|
+
used when this same target list's port scan is being reused for
|
|
265
|
+
another round of checks (run_one_scan's post-scan loop), so there's an
|
|
266
|
+
explicit, discoverable way to leave that loop and go pick a different
|
|
267
|
+
target/subnet, not just Ctrl-C. It can't be combined with real check
|
|
268
|
+
numbers in one comma-separated selection -- it only fires when it's
|
|
269
|
+
the ONLY thing typed.
|
|
270
|
+
|
|
271
|
+
Returns the chosen mode_name(s), always in the SAME order as `options`
|
|
272
|
+
(never the order the user typed them, and never duplicated), or every
|
|
273
|
+
mode_name if "all" was chosen. Loops on invalid input; Ctrl-C/EOF here
|
|
274
|
+
raises ReturnToMainMenu (bounces back to the top-level menu) rather than
|
|
275
|
+
quitting the process outright.
|
|
276
|
+
"""
|
|
277
|
+
print(_centered_line(accent("Select which check(s) to run:")))
|
|
278
|
+
print()
|
|
279
|
+
|
|
280
|
+
all_index = len(options) + 1
|
|
281
|
+
back_index = all_index + 1 if allow_back else None
|
|
282
|
+
# Every bracketed number (1 through however many options there are,
|
|
283
|
+
# plus "All checks" and, when present, "Back to main menu") is
|
|
284
|
+
# right-aligned inside a common width, so "[1]" and "[14]" both end
|
|
285
|
+
# in the same screen column instead of the closing bracket drifting
|
|
286
|
+
# right by one character per extra digit.
|
|
287
|
+
num_width = len(str(back_index if allow_back else all_index))
|
|
288
|
+
|
|
289
|
+
menu_lines = [
|
|
290
|
+
accent(f" [{i:>{num_width}}]") + " " + c(desc, "white")
|
|
291
|
+
for i, (_, desc) in enumerate(options, start=1)
|
|
292
|
+
]
|
|
293
|
+
menu_lines.append(accent(f" [{all_index:>{num_width}}]") + " " + c("All checks", "white"))
|
|
294
|
+
if allow_back:
|
|
295
|
+
menu_lines.append(
|
|
296
|
+
accent(f" [{back_index:>{num_width}}]") + " " +
|
|
297
|
+
c("Back to main menu", "white") + muted(" -- change target / subnet")
|
|
298
|
+
)
|
|
299
|
+
_emit_numbered_menu(menu_lines)
|
|
300
|
+
print()
|
|
301
|
+
|
|
302
|
+
while True:
|
|
303
|
+
try:
|
|
304
|
+
raw = input(_centered_line(prompt(f" Select mode(s) (e.g. 1,3 or 'all'): "))).strip()
|
|
305
|
+
except (EOFError, KeyboardInterrupt):
|
|
306
|
+
print()
|
|
307
|
+
raise ReturnToMainMenu()
|
|
308
|
+
|
|
309
|
+
if not raw:
|
|
310
|
+
print(_centered_line(warn("No selection entered") + muted(" -- try again, or Ctrl-C to quit")))
|
|
311
|
+
continue
|
|
312
|
+
|
|
313
|
+
low = raw.lower()
|
|
314
|
+
if allow_back and low in ("back", "b", str(back_index)):
|
|
315
|
+
print()
|
|
316
|
+
raise ReturnToMainMenu()
|
|
317
|
+
if low == "all" or low == str(all_index):
|
|
318
|
+
print()
|
|
319
|
+
return [name for name, _ in options]
|
|
320
|
+
|
|
321
|
+
chosen_indices = []
|
|
322
|
+
valid = True
|
|
323
|
+
for part in raw.split(","):
|
|
324
|
+
part = part.strip()
|
|
325
|
+
if not part.isdigit() or not (1 <= int(part) <= len(options)):
|
|
326
|
+
valid = False
|
|
327
|
+
break
|
|
328
|
+
chosen_indices.append(int(part))
|
|
329
|
+
|
|
330
|
+
if not valid or not chosen_indices:
|
|
331
|
+
hint = f"1-{all_index}, comma-separated, 'all'" + (", or 'back'" if allow_back else "")
|
|
332
|
+
print(_centered_line(warn(f"Invalid selection '{raw}'") + muted(f" -- enter number(s) {hint}.")))
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
chosen_set = set(chosen_indices)
|
|
336
|
+
chosen_names = [name for i, (name, _) in enumerate(options, start=1) if i in chosen_set]
|
|
337
|
+
print()
|
|
338
|
+
return chosen_names
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def prompt_port_list(label: str, verb: str = "check") -> str:
|
|
342
|
+
"""
|
|
343
|
+
Asks for the port(s) a mode with no fixed port of its own should use --
|
|
344
|
+
a single port ("80") or several, comma-separated ("80,81,443,8080").
|
|
345
|
+
Used by any check that has to ask (currently DIRSEARCH and CORS), right
|
|
346
|
+
after the user picks that mode, instead of the port being decided up
|
|
347
|
+
front like every other check.
|
|
348
|
+
|
|
349
|
+
label is the mode's display name ("DIRSEARCH", "CORS"); verb describes
|
|
350
|
+
what happens to those ports ("brute-force", "check") so the prompt
|
|
351
|
+
reads naturally for either.
|
|
352
|
+
|
|
353
|
+
Returns the raw string as typed -- parsing/validating it into real port
|
|
354
|
+
numbers is the caller's job (cli.py's _parse_port_list), same division
|
|
355
|
+
of responsibility as the rest of this file. Loops on empty input;
|
|
356
|
+
Ctrl-C/EOF raises ReturnToMainMenu rather than quitting the process
|
|
357
|
+
outright.
|
|
358
|
+
"""
|
|
359
|
+
print(_centered_line(accent(f"{label} needs to know which port(s) to {verb}.")))
|
|
360
|
+
while True:
|
|
361
|
+
try:
|
|
362
|
+
raw = input(_centered_line(prompt(" Enter port(s) (e.g. 80 or 80,443,8080): "))).strip()
|
|
363
|
+
except (EOFError, KeyboardInterrupt):
|
|
364
|
+
print()
|
|
365
|
+
raise ReturnToMainMenu()
|
|
366
|
+
if not raw:
|
|
367
|
+
print(_centered_line(warn("No port(s) entered") + muted(" -- try again, or Ctrl-C for the main menu")))
|
|
368
|
+
continue
|
|
369
|
+
return raw
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parses `openssl s_client -showcerts` raw output into a plain structure the
|
|
3
|
+
rule engine can use. Only text-parses two things, both stable/documented:
|
|
4
|
+
- the "Verify return code: N (message)" line
|
|
5
|
+
- how many certificates the SERVER actually sent (not how many the local
|
|
6
|
+
trust store has) -- this is what lets us tell "the server forgot to
|
|
7
|
+
send its intermediate" (a real misconfig) apart from "the ultimate root
|
|
8
|
+
just isn't in this trust store" (a different, less actionable finding),
|
|
9
|
+
which a bare verify-code check alone can't distinguish.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
_VERIFY_RE = re.compile(r"Verify return code:\s*(\d+)\s*\(([^)]*)\)")
|
|
18
|
+
_SUBJECT_RE = re.compile(r"^\s*subject=(.*)$", re.MULTILINE)
|
|
19
|
+
_ISSUER_RE = re.compile(r"^\s*issuer=(.*)$", re.MULTILINE)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ChainResult:
|
|
24
|
+
host: str
|
|
25
|
+
port: int
|
|
26
|
+
verify_code: Optional[int] = None
|
|
27
|
+
verify_message: str = ""
|
|
28
|
+
certs_sent: int = 0
|
|
29
|
+
leaf_subject: str = ""
|
|
30
|
+
leaf_issuer: str = ""
|
|
31
|
+
raw_text: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_chain_text(raw_text: str, host: str, port: int) -> ChainResult:
|
|
35
|
+
result = ChainResult(host=host, port=port, raw_text=raw_text)
|
|
36
|
+
|
|
37
|
+
m = _VERIFY_RE.search(raw_text)
|
|
38
|
+
if m:
|
|
39
|
+
result.verify_code = int(m.group(1))
|
|
40
|
+
result.verify_message = m.group(2).strip()
|
|
41
|
+
|
|
42
|
+
result.certs_sent = raw_text.count("-----BEGIN CERTIFICATE-----")
|
|
43
|
+
|
|
44
|
+
m = _SUBJECT_RE.search(raw_text)
|
|
45
|
+
if m:
|
|
46
|
+
result.leaf_subject = m.group(1).strip()
|
|
47
|
+
m = _ISSUER_RE.search(raw_text)
|
|
48
|
+
if m:
|
|
49
|
+
result.leaf_issuer = m.group(1).strip()
|
|
50
|
+
|
|
51
|
+
return result
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rule engine for the cert-chain-of-trust family (#12 Untrusted/Self-Signed,
|
|
3
|
+
#13 Incomplete Certificate Chain). Both read the same ChainResult; which
|
|
4
|
+
one fires (if any) depends on the verify code AND how many certificates the
|
|
5
|
+
server actually sent -- that second part is what keeps "server forgot the
|
|
6
|
+
intermediate" (genuinely incomplete chain) distinct from "chain is complete
|
|
7
|
+
but the root just isn't trusted here" (an untrusted-CA situation, bucketed
|
|
8
|
+
with self-signed since both are fundamentally "a client won't trust this").
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from netscraper.core.cert_chain_parser import ChainResult
|
|
16
|
+
from netscraper.core.rules import Finding
|
|
17
|
+
|
|
18
|
+
SELF_SIGNED_CODES = {18, 19} # X509_V_ERR_DEPTH_ZERO_SELF_SIGNED_CERT / SELF_SIGNED_CERT_IN_CHAIN
|
|
19
|
+
ISSUER_UNKNOWN_CODES = {2, 20, 21} # unable to get issuer cert (locally / at all) / unable to verify first cert
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_self_signed_or_untrusted(result: ChainResult) -> Optional[Finding]:
|
|
23
|
+
if result.verify_code is None or result.verify_code == 0:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
if result.verify_code in SELF_SIGNED_CODES:
|
|
27
|
+
return Finding(
|
|
28
|
+
rule_id="self_signed_certificate",
|
|
29
|
+
label="Untrusted / Self-Signed SSL Certificate",
|
|
30
|
+
folder_name="Untrusted_Self_Signed",
|
|
31
|
+
severity="medium",
|
|
32
|
+
evidence_lines=[
|
|
33
|
+
f"Subject: {result.leaf_subject}",
|
|
34
|
+
f"Issuer: {result.leaf_issuer}",
|
|
35
|
+
f"Verify return code: {result.verify_code} ({result.verify_message})",
|
|
36
|
+
"--> Certificate is self-signed (subject and issuer match; not issued by a trusted CA)",
|
|
37
|
+
],
|
|
38
|
+
highlight_regex=re.compile(r"Verify return code:|^\s*(subject|issuer)=", re.IGNORECASE),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if result.verify_code in ISSUER_UNKNOWN_CODES and result.certs_sent >= 2:
|
|
42
|
+
# Server DID send a chain (leaf + at least one intermediate) -- the
|
|
43
|
+
# problem is the ultimate root isn't in this trust store, not that
|
|
44
|
+
# the server left something out. Bucketed with self-signed since
|
|
45
|
+
# both mean "a normal client will show a trust warning here."
|
|
46
|
+
return Finding(
|
|
47
|
+
rule_id="untrusted_ca",
|
|
48
|
+
label="Certificate Signed by Untrusted/Unknown CA",
|
|
49
|
+
folder_name="Untrusted_Self_Signed",
|
|
50
|
+
severity="medium",
|
|
51
|
+
evidence_lines=[
|
|
52
|
+
f"Subject: {result.leaf_subject}",
|
|
53
|
+
f"Issuer: {result.leaf_issuer}",
|
|
54
|
+
f"Certificates sent by server: {result.certs_sent}",
|
|
55
|
+
f"Verify return code: {result.verify_code} ({result.verify_message})",
|
|
56
|
+
"--> Chain was sent, but does not resolve to a CA trusted by a standard client",
|
|
57
|
+
],
|
|
58
|
+
highlight_regex=re.compile(r"Verify return code:|^\s*(subject|issuer)=", re.IGNORECASE),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_incomplete_chain(result: ChainResult) -> Optional[Finding]:
|
|
65
|
+
if result.verify_code is None or result.verify_code == 0:
|
|
66
|
+
return None
|
|
67
|
+
if result.verify_code in ISSUER_UNKNOWN_CODES and result.certs_sent <= 1:
|
|
68
|
+
return Finding(
|
|
69
|
+
rule_id="incomplete_certificate_chain",
|
|
70
|
+
label="Incomplete SSL/TLS Certificate Chain",
|
|
71
|
+
folder_name="Incomplete_Certificate_Chain",
|
|
72
|
+
severity="medium",
|
|
73
|
+
evidence_lines=[
|
|
74
|
+
f"Subject: {result.leaf_subject}",
|
|
75
|
+
f"Issuer: {result.leaf_issuer}",
|
|
76
|
+
f"Certificates sent by server: {result.certs_sent} (leaf only, no intermediate)",
|
|
77
|
+
f"Verify return code: {result.verify_code} ({result.verify_message})",
|
|
78
|
+
"--> Server did not send the intermediate certificate(s) needed to build a trust chain",
|
|
79
|
+
],
|
|
80
|
+
highlight_regex=re.compile(r"Verify return code:|^\s*(subject|issuer)=", re.IGNORECASE),
|
|
81
|
+
)
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
RULES = [
|
|
86
|
+
check_self_signed_or_untrusted,
|
|
87
|
+
check_incomplete_chain,
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def evaluate(result: ChainResult) -> List[Finding]:
|
|
92
|
+
findings = []
|
|
93
|
+
for rule in RULES:
|
|
94
|
+
finding = rule(result)
|
|
95
|
+
if finding:
|
|
96
|
+
findings.append(finding)
|
|
97
|
+
return findings
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runs `openssl s_client -showcerts` against a target to determine trust-chain
|
|
3
|
+
status: self-signed, incomplete chain, or fully trusted. Unlike sslscan,
|
|
4
|
+
openssl s_client's output is never colorized, so a plain subprocess capture
|
|
5
|
+
is enough -- no pty needed here, and its output is already both
|
|
6
|
+
human-readable AND has a stable, well-documented "Verify return code: N
|
|
7
|
+
(message)" line we can parse directly (see openssl-verify(1) for the full
|
|
8
|
+
X509_V_ERR_* code list). One invocation gives us everything.
|
|
9
|
+
|
|
10
|
+
Verification uses the SYSTEM's default trust store (no -CAfile override),
|
|
11
|
+
matching what a normal client/browser would decide. Note: in a sandboxed
|
|
12
|
+
environment where outbound TLS is intercepted (see README), this check is
|
|
13
|
+
only meaningful against loopback/local targets or when run from a real
|
|
14
|
+
network position -- an intercepting proxy's own CA will make every
|
|
15
|
+
external host look untrusted regardless of the real target's actual
|
|
16
|
+
certificate.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import subprocess
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from netscraper.core.targets import Target
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CertChainError(RuntimeError):
|
|
27
|
+
def __init__(self, target: Target, message: str):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.target = target
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_live(target: Target, timeout: int = 20) -> str:
|
|
33
|
+
cmd = [
|
|
34
|
+
"openssl", "s_client",
|
|
35
|
+
"-connect", f"{target.host}:{target.port}",
|
|
36
|
+
"-servername", target.host,
|
|
37
|
+
"-showcerts",
|
|
38
|
+
]
|
|
39
|
+
try:
|
|
40
|
+
proc = subprocess.run(
|
|
41
|
+
cmd,
|
|
42
|
+
input="",
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
timeout=timeout,
|
|
46
|
+
)
|
|
47
|
+
except subprocess.TimeoutExpired as exc:
|
|
48
|
+
raise CertChainError(target, f"openssl s_client timed out after {timeout}s") from exc
|
|
49
|
+
|
|
50
|
+
output = (proc.stdout or "") + (proc.stderr or "")
|
|
51
|
+
if "Verify return code" not in output:
|
|
52
|
+
raise CertChainError(
|
|
53
|
+
target,
|
|
54
|
+
f"openssl s_client produced no usable output (exit={proc.returncode})",
|
|
55
|
+
)
|
|
56
|
+
return output
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def run_replay(target: Target, xml_dir: str) -> str:
|
|
60
|
+
candidate = Path(xml_dir) / f"{target.label}_chain.txt"
|
|
61
|
+
if not candidate.exists():
|
|
62
|
+
candidate = Path(xml_dir) / f"{target.host}_chain.txt"
|
|
63
|
+
if not candidate.exists():
|
|
64
|
+
raise CertChainError(
|
|
65
|
+
target, f"No captured cert-chain output found for {target.host} in {xml_dir}"
|
|
66
|
+
)
|
|
67
|
+
return candidate.read_text(encoding="utf-8")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_chain_text(target: Target, xml_dir: str | None, timeout: int = 20) -> str:
|
|
71
|
+
if xml_dir:
|
|
72
|
+
return run_replay(target, xml_dir)
|
|
73
|
+
return run_live(target, timeout=timeout)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Works out how many hosts to scan at once -- automatically, with no flag or
|
|
3
|
+
prompt exposed anywhere. The whole point of this module is that a caller
|
|
4
|
+
just asks worker_count() and gets back a safe, real number; there is no
|
|
5
|
+
"ask the user" path at all.
|
|
6
|
+
|
|
7
|
+
Why this exists: a target list of dozens/hundreds of hosts run one host at
|
|
8
|
+
a time (the original design) means the wall-clock cost of a check scales
|
|
9
|
+
linearly with the target list size -- fine for 5 hosts, painfully slow for
|
|
10
|
+
64 or 200. Running several hosts' worth of the SAME check at once (a
|
|
11
|
+
process pool -- see core/scan_worker.py for why it must be *processes*,
|
|
12
|
+
not threads) cuts that wall-clock time roughly in proportion to how many
|
|
13
|
+
run in parallel. But running too many at once on a modest machine (each
|
|
14
|
+
worker can spin up its own Chromium for POC rendering, plus whatever
|
|
15
|
+
external tool -- sslscan, nmap, dirsearch -- that check shells out to)
|
|
16
|
+
risks starving the machine instead of actually finishing faster. So the
|
|
17
|
+
number of workers is derived from the machine's own resources, not a
|
|
18
|
+
fixed constant and never something the user is asked to set.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
|
|
25
|
+
# Rough per-worker memory budget. Each worker can, at its peak, be holding
|
|
26
|
+
# open one Chromium instance (POC rendering) alongside whatever external
|
|
27
|
+
# process a check shells out to (sslscan/nmap/dirsearch/curl/...) --
|
|
28
|
+
# 1.5 GB/worker is a conservative real-world number for that combination
|
|
29
|
+
# that still lets an 8 GB box comfortably run several workers at once
|
|
30
|
+
# without swapping.
|
|
31
|
+
_GB_PER_WORKER = 1.5
|
|
32
|
+
|
|
33
|
+
# Absolute floor/ceiling regardless of what the machine reports. Below 2,
|
|
34
|
+
# "parallel" isn't buying anything meaningful; above 8 the user's own
|
|
35
|
+
# guidance ("maybe eight, six") is the target ceiling -- more workers than
|
|
36
|
+
# that mostly just means more processes contending for the same network
|
|
37
|
+
# link and the same target host's own connection limits, not real extra
|
|
38
|
+
# throughput.
|
|
39
|
+
_MIN_WORKERS = 2
|
|
40
|
+
_MAX_WORKERS = 8
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _total_ram_gb() -> float | None:
|
|
44
|
+
"""
|
|
45
|
+
Best-effort total system RAM in GiB. Returns None if it can't be
|
|
46
|
+
determined on this platform -- the caller falls back to a safe
|
|
47
|
+
default rather than guessing.
|
|
48
|
+
"""
|
|
49
|
+
# Linux: os.sysconf exposes page size * total pages directly, no
|
|
50
|
+
# subprocess needed.
|
|
51
|
+
try:
|
|
52
|
+
pages = os.sysconf("SC_PHYS_PAGES")
|
|
53
|
+
page_size = os.sysconf("SC_PAGE_SIZE")
|
|
54
|
+
if pages > 0 and page_size > 0:
|
|
55
|
+
return (pages * page_size) / (1024 ** 3)
|
|
56
|
+
except (ValueError, OSError, AttributeError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
# macOS: no SC_PHYS_PAGES: ask sysctl directly instead.
|
|
60
|
+
try:
|
|
61
|
+
out = subprocess.run(
|
|
62
|
+
["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=2
|
|
63
|
+
)
|
|
64
|
+
if out.returncode == 0 and out.stdout.strip().isdigit():
|
|
65
|
+
return int(out.stdout.strip()) / (1024 ** 3)
|
|
66
|
+
except (OSError, subprocess.SubprocessError):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def worker_count(target_count: int | None = None) -> int:
|
|
73
|
+
"""
|
|
74
|
+
Returns how many hosts to scan at once for a single check, clamped to
|
|
75
|
+
[_MIN_WORKERS, _MAX_WORKERS]. Never higher than the number of targets
|
|
76
|
+
actually queued (no point launching 8 workers for 3 hosts), and never
|
|
77
|
+
higher than the CPU count either (more workers than cores just means
|
|
78
|
+
context-switch overhead, not real parallelism).
|
|
79
|
+
|
|
80
|
+
Formula: total RAM / _GB_PER_WORKER, rounded down -- e.g. an 8 GB box
|
|
81
|
+
lands around 5, a 16 GB box around 8 (the middle of the user's own
|
|
82
|
+
"six, eight" guidance for machines in that 8-16 GB range). If RAM
|
|
83
|
+
can't be detected at all, assumes a conservative 8 GB baseline rather
|
|
84
|
+
than either refusing to parallelize or guessing high.
|
|
85
|
+
"""
|
|
86
|
+
ram_gb = _total_ram_gb()
|
|
87
|
+
if ram_gb is None:
|
|
88
|
+
ram_gb = 8.0
|
|
89
|
+
|
|
90
|
+
ram_based = int(ram_gb // _GB_PER_WORKER)
|
|
91
|
+
cpu_based = os.cpu_count() or _MAX_WORKERS
|
|
92
|
+
|
|
93
|
+
count = max(_MIN_WORKERS, min(_MAX_WORKERS, ram_based, cpu_based))
|
|
94
|
+
|
|
95
|
+
if target_count is not None and target_count > 0:
|
|
96
|
+
count = min(count, target_count)
|
|
97
|
+
|
|
98
|
+
return max(1, count)
|