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.
Files changed (60) hide show
  1. netscraper/__init__.py +9 -0
  2. netscraper/cli.py +635 -0
  3. netscraper/core/__init__.py +0 -0
  4. netscraper/core/banner.py +369 -0
  5. netscraper/core/cert_chain_parser.py +51 -0
  6. netscraper/core/cert_chain_rules.py +97 -0
  7. netscraper/core/cert_chain_runner.py +73 -0
  8. netscraper/core/concurrency.py +98 -0
  9. netscraper/core/cors_parser.py +45 -0
  10. netscraper/core/cors_rules.py +71 -0
  11. netscraper/core/cors_runner.py +100 -0
  12. netscraper/core/dirsearch_parser.py +48 -0
  13. netscraper/core/dirsearch_rules.py +39 -0
  14. netscraper/core/dirsearch_runner.py +109 -0
  15. netscraper/core/dns_parser.py +34 -0
  16. netscraper/core/dns_rules.py +57 -0
  17. netscraper/core/dns_runner.py +68 -0
  18. netscraper/core/excel_report.py +84 -0
  19. netscraper/core/families.py +467 -0
  20. netscraper/core/ftp_parser.py +28 -0
  21. netscraper/core/ftp_rules.py +56 -0
  22. netscraper/core/ftp_runner.py +63 -0
  23. netscraper/core/http_parser.py +59 -0
  24. netscraper/core/http_rules.py +81 -0
  25. netscraper/core/http_runner.py +118 -0
  26. netscraper/core/ldap_parser.py +53 -0
  27. netscraper/core/ldap_rules.py +109 -0
  28. netscraper/core/ldap_runner.py +100 -0
  29. netscraper/core/mssql_parser.py +26 -0
  30. netscraper/core/mssql_rules.py +35 -0
  31. netscraper/core/mssql_runner.py +70 -0
  32. netscraper/core/poc_renderer.py +399 -0
  33. netscraper/core/port_gate.py +79 -0
  34. netscraper/core/rpc_parser.py +59 -0
  35. netscraper/core/rpc_rules.py +86 -0
  36. netscraper/core/rpc_runner.py +82 -0
  37. netscraper/core/rules.py +234 -0
  38. netscraper/core/scan_worker.py +182 -0
  39. netscraper/core/smb_parser.py +69 -0
  40. netscraper/core/smb_rules.py +121 -0
  41. netscraper/core/smb_runner.py +123 -0
  42. netscraper/core/snmp_parser.py +23 -0
  43. netscraper/core/snmp_pure_probe.py +201 -0
  44. netscraper/core/snmp_rules.py +38 -0
  45. netscraper/core/snmp_runner.py +95 -0
  46. netscraper/core/ssh_parser.py +50 -0
  47. netscraper/core/ssh_rules.py +76 -0
  48. netscraper/core/ssh_runner.py +91 -0
  49. netscraper/core/sslscan_parser.py +140 -0
  50. netscraper/core/sslscan_runner.py +145 -0
  51. netscraper/core/targets.py +96 -0
  52. netscraper/core/terminal_capture.py +112 -0
  53. netscraper/core/theme.py +97 -0
  54. netscraper/wordlists/snmp_communities.txt +16 -0
  55. netscraper-0.1.0.dist-info/METADATA +695 -0
  56. netscraper-0.1.0.dist-info/RECORD +60 -0
  57. netscraper-0.1.0.dist-info/WHEEL +5 -0
  58. netscraper-0.1.0.dist-info/entry_points.txt +2 -0
  59. netscraper-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. 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)