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,467 @@
1
+ """
2
+ Orchestration layer: one function per check "family" that knows how to run
3
+ and evaluate everything for a single qualifying host. cli.py stays generic
4
+ -- it just gates ports, calls the right family function per host, and
5
+ routes whatever comes back to the right folders. Adding a new family later
6
+ (SMB, LDAP, MSSQL, ...) means adding one more function here with the same
7
+ shape, not touching the orchestration logic in cli.py.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from typing import List, Optional, Tuple
14
+
15
+ from netscraper.core.targets import Target
16
+ from netscraper.core.rules import Finding
17
+
18
+ from netscraper.core.sslscan_runner import get_xml as sslscan_get_xml, get_ansi_text as sslscan_get_ansi
19
+ from netscraper.core.sslscan_parser import parse_xml as sslscan_parse
20
+ from netscraper.core.rules import evaluate as sslscan_evaluate
21
+
22
+ from netscraper.core.cert_chain_runner import get_chain_text
23
+ from netscraper.core.cert_chain_parser import parse_chain_text
24
+ from netscraper.core.cert_chain_rules import evaluate as cert_chain_evaluate
25
+
26
+ from netscraper.core.ssh_runner import get_json as ssh_get_json, get_ansi_text as ssh_get_ansi
27
+ from netscraper.core.ssh_parser import parse_ssh_audit_json
28
+ from netscraper.core.ssh_rules import evaluate as ssh_evaluate
29
+
30
+ from netscraper.core.smb_runner import get_nmap_text as smb_get_nmap, get_smbclient_text as smb_get_smbclient
31
+ from netscraper.core.smb_parser import parse_smb_text
32
+ from netscraper.core.smb_rules import evaluate as smb_evaluate
33
+
34
+ from netscraper.core.ldap_runner import get_anon_text as ldap_get_anon, get_signing_probe_text as ldap_get_signing, BOGUS_DN, BOGUS_PW
35
+ from netscraper.core.ldap_parser import parse_ldap_text
36
+ from netscraper.core.ldap_rules import evaluate as ldap_evaluate
37
+
38
+ from netscraper.core.rpc_runner import get_rpcdump_text as rpc_get_rpcdump
39
+ from netscraper.core.rpc_parser import parse_rpcdump_text
40
+ from netscraper.core.rpc_rules import evaluate as rpc_evaluate
41
+
42
+ from netscraper.core.dns_runner import get_dig_text as dns_get_dig
43
+ from netscraper.core.dns_parser import parse_dig_text
44
+ from netscraper.core.dns_rules import evaluate as dns_evaluate
45
+
46
+ from netscraper.core.snmp_runner import get_onesixtyone_text as snmp_get_onesixtyone
47
+ from netscraper.core.snmp_parser import parse_onesixtyone_text
48
+ from netscraper.core.snmp_rules import evaluate as snmp_evaluate
49
+
50
+ from netscraper.core.ftp_runner import get_ftp_text as ftp_get_ftp
51
+ from netscraper.core.ftp_parser import parse_ftp_text
52
+ from netscraper.core.ftp_rules import evaluate as ftp_evaluate
53
+
54
+ from netscraper.core.mssql_runner import get_nmap_text as mssql_get_nmap
55
+ from netscraper.core.mssql_parser import parse_mssql_text
56
+ from netscraper.core.mssql_rules import evaluate as mssql_evaluate
57
+
58
+ from netscraper.core.http_runner import (
59
+ get_version_text as http_get_version,
60
+ get_host_header_text as http_get_host_header,
61
+ get_trace_text as http_get_trace,
62
+ marker_for as http_marker_for,
63
+ )
64
+ from netscraper.core.http_parser import parse_http_result
65
+ from netscraper.core.http_rules import evaluate as http_evaluate
66
+
67
+ from netscraper.core.dirsearch_runner import get_dirsearch_text
68
+ from netscraper.core.dirsearch_parser import parse_dirsearch_text
69
+ from netscraper.core.dirsearch_rules import evaluate as dirsearch_evaluate
70
+
71
+ from netscraper.core.cors_runner import get_cors_text, EVIL_ORIGIN
72
+ from netscraper.core.cors_parser import parse_cors_text
73
+ from netscraper.core.cors_rules import evaluate as cors_evaluate
74
+
75
+
76
+ @dataclass
77
+ class FamilyHostResult:
78
+ status: str # "vulnerable" | "safe" | "error" -- informational; cli.py actually
79
+ # branches on whether findings/manual_checks are non-empty, so a
80
+ # host can carry confirmed findings AND manual-verification items
81
+ # from the same family at once (e.g. LDAP: anonymous bind confirmed
82
+ # vulnerable, signing enforcement flagged for manual verification).
83
+ # (finding, raw_text_to_render_this_finding_from, real_command_that_produced_it)
84
+ # -- confirmed Vulnerable/Safe verdict. The command string is shown verbatim
85
+ # on the POC (title bar + prompt line) so every family's screenshot honestly
86
+ # reflects which real tool/invocation produced it, instead of a hardcoded name.
87
+ findings: List[Tuple[Finding, str, str]] = field(default_factory=list)
88
+ # Same shape, but for checks whose result can't be verdicted automatically
89
+ # without a false-positive/false-negative risk (e.g. behaviorally-fuzzy
90
+ # protocol checks) -- routed to a "Needs Manual Verification" folder
91
+ # instead of being forced into Vulnerable or Safe.
92
+ manual_checks: List[Tuple[Finding, str, str]] = field(default_factory=list)
93
+ safe_raw_text: Optional[str] = None
94
+ safe_command: Optional[str] = None
95
+ error_message: Optional[str] = None
96
+ raw_artifacts: List[Tuple[str, str]] = field(default_factory=list) # (label, raw_text) for audit trail
97
+
98
+
99
+ def run_tls_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
100
+ try:
101
+ xml_text = sslscan_get_xml(target, xml_dir, timeout=timeout)
102
+ ansi_text = sslscan_get_ansi(target, xml_dir, timeout=timeout)
103
+ chain_text = get_chain_text(target, xml_dir, timeout=timeout)
104
+ except Exception as exc: # noqa: BLE001
105
+ return FamilyHostResult(status="error", error_message=str(exc))
106
+
107
+ try:
108
+ scan_result = sslscan_parse(xml_text, host=target.host, port=target.port)
109
+ chain_result = parse_chain_text(chain_text, host=target.host, port=target.port)
110
+ except Exception as exc: # noqa: BLE001
111
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
112
+
113
+ sslscan_cmd = f"sslscan {target.host}:{target.port}"
114
+ chain_cmd = f"openssl s_client -connect {target.host}:{target.port} -servername {target.host} -showcerts"
115
+
116
+ findings: List[Tuple[Finding, str, str]] = []
117
+ for f in sslscan_evaluate(scan_result):
118
+ findings.append((f, ansi_text, sslscan_cmd))
119
+ for f in cert_chain_evaluate(chain_result):
120
+ findings.append((f, chain_text, chain_cmd))
121
+
122
+ raw_artifacts = [("sslscan", xml_text), ("sslscan_terminal", ansi_text), ("cert_chain", chain_text)]
123
+
124
+ if not findings:
125
+ return FamilyHostResult(status="safe", safe_raw_text=ansi_text, safe_command=sslscan_cmd, raw_artifacts=raw_artifacts)
126
+ return FamilyHostResult(status="vulnerable", findings=findings, raw_artifacts=raw_artifacts)
127
+
128
+
129
+ def run_ssh_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
130
+ try:
131
+ json_data = ssh_get_json(target, xml_dir, timeout=timeout)
132
+ ansi_text = ssh_get_ansi(target, xml_dir, timeout=timeout)
133
+ except Exception as exc: # noqa: BLE001
134
+ return FamilyHostResult(status="error", error_message=str(exc))
135
+
136
+ try:
137
+ result = parse_ssh_audit_json(json_data, host=target.host, port=target.port)
138
+ except Exception as exc: # noqa: BLE001
139
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
140
+
141
+ ssh_cmd = f"ssh-audit {target.host}:{target.port}"
142
+ findings = [(f, ansi_text, ssh_cmd) for f in ssh_evaluate(result)]
143
+ raw_artifacts = [("ssh_audit_terminal", ansi_text)]
144
+
145
+ if not findings:
146
+ return FamilyHostResult(status="safe", safe_raw_text=ansi_text, safe_command=ssh_cmd, raw_artifacts=raw_artifacts)
147
+ return FamilyHostResult(status="vulnerable", findings=findings, raw_artifacts=raw_artifacts)
148
+
149
+
150
+ def run_smb_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
151
+ try:
152
+ nmap_text = smb_get_nmap(target, xml_dir, timeout=timeout)
153
+ smbclient_text = smb_get_smbclient(target, xml_dir, timeout=timeout)
154
+ except Exception as exc: # noqa: BLE001
155
+ return FamilyHostResult(status="error", error_message=str(exc))
156
+
157
+ try:
158
+ result = parse_smb_text(nmap_text, smbclient_text, host=target.host, port=target.port)
159
+ except Exception as exc: # noqa: BLE001
160
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
161
+
162
+ nmap_cmd = (
163
+ f"nmap -p {target.port} --script smb-protocols,smb-security-mode,smb2-security-mode "
164
+ f"--script-args smbport={target.port} {target.host}"
165
+ )
166
+ smbclient_cmd = f"smbclient -L //{target.host} -p {target.port} -N"
167
+
168
+ confirmed, manual = smb_evaluate(result)
169
+ # nmap findings render from nmap_text (SMBv1 / signing); the anonymous-access
170
+ # finding renders from smbclient_text -- each POC highlights only its own tool's
171
+ # real output.
172
+ findings = [
173
+ (f, smbclient_text, smbclient_cmd) if f.rule_id == "smb_null_session_anonymous_access"
174
+ else (f, nmap_text, nmap_cmd)
175
+ for f in confirmed
176
+ ]
177
+ manual_checks = [(f, nmap_text, nmap_cmd) for f in manual]
178
+ raw_artifacts = [("smb_nmap", nmap_text), ("smbclient", smbclient_text)]
179
+
180
+ if not findings and not manual_checks:
181
+ safe_text = nmap_text + "\n\n$ " + smbclient_cmd + "\n" + smbclient_text
182
+ return FamilyHostResult(
183
+ status="safe", safe_raw_text=safe_text, safe_command=nmap_cmd, raw_artifacts=raw_artifacts,
184
+ )
185
+ return FamilyHostResult(
186
+ status="vulnerable" if findings else "safe",
187
+ findings=findings,
188
+ manual_checks=manual_checks,
189
+ raw_artifacts=raw_artifacts,
190
+ )
191
+
192
+
193
+ def run_ldap_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
194
+ try:
195
+ anon_text = ldap_get_anon(target, xml_dir, timeout=timeout)
196
+ signing_text = ldap_get_signing(target, xml_dir, timeout=timeout)
197
+ except Exception as exc: # noqa: BLE001
198
+ return FamilyHostResult(status="error", error_message=str(exc))
199
+
200
+ try:
201
+ result = parse_ldap_text(anon_text, signing_text, host=target.host, port=target.port)
202
+ except Exception as exc: # noqa: BLE001
203
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
204
+
205
+ anon_cmd = f"ldapsearch -x -H ldap://{target.host}:{target.port} -b \"\" -s base"
206
+ signing_cmd = f"ldapsearch -x -D \"{BOGUS_DN}\" -w {BOGUS_PW} -H ldap://{target.host}:{target.port} -b \"\" -s base"
207
+
208
+ confirmed, manual = ldap_evaluate(result)
209
+ findings = [
210
+ (f, signing_text, signing_cmd) if f.rule_id == "ldap_signing_not_enforced" else (f, anon_text, anon_cmd)
211
+ for f in confirmed
212
+ ]
213
+ manual_checks = [
214
+ (f, signing_text, signing_cmd) if f.rule_id == "ldap_signing_unknown" else (f, anon_text, anon_cmd)
215
+ for f in manual
216
+ ]
217
+ raw_artifacts = [("ldap_anon", anon_text), ("ldap_signing_probe", signing_text)]
218
+
219
+ if not findings and not manual_checks:
220
+ safe_text = anon_text + "\n\n$ " + signing_cmd + "\n" + signing_text
221
+ return FamilyHostResult(
222
+ status="safe", safe_raw_text=safe_text, safe_command=anon_cmd, raw_artifacts=raw_artifacts,
223
+ )
224
+ return FamilyHostResult(
225
+ status="vulnerable" if findings else "safe",
226
+ findings=findings,
227
+ manual_checks=manual_checks,
228
+ raw_artifacts=raw_artifacts,
229
+ )
230
+
231
+
232
+ def run_rpc_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
233
+ try:
234
+ rpcdump_text = rpc_get_rpcdump(target, xml_dir, timeout=timeout)
235
+ except Exception as exc: # noqa: BLE001
236
+ return FamilyHostResult(status="error", error_message=str(exc))
237
+
238
+ try:
239
+ result = parse_rpcdump_text(rpcdump_text, host=target.host, port=target.port)
240
+ except Exception as exc: # noqa: BLE001
241
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
242
+
243
+ rpc_cmd = f"rpcdump.py -port {target.port if target.port in (135, 139, 443, 445, 593) else 445} ''@{target.host}"
244
+ confirmed, manual = rpc_evaluate(result)
245
+ findings = [(f, rpcdump_text, rpc_cmd) for f in confirmed]
246
+ manual_checks = [(f, rpcdump_text, rpc_cmd) for f in manual]
247
+ raw_artifacts = [("rpcdump", rpcdump_text)]
248
+
249
+ if not findings and not manual_checks:
250
+ return FamilyHostResult(
251
+ status="safe", safe_raw_text=rpcdump_text, safe_command=rpc_cmd, raw_artifacts=raw_artifacts,
252
+ )
253
+ return FamilyHostResult(
254
+ status="vulnerable" if findings else "safe",
255
+ findings=findings,
256
+ manual_checks=manual_checks,
257
+ raw_artifacts=raw_artifacts,
258
+ )
259
+
260
+
261
+ def run_dns_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
262
+ try:
263
+ dig_text = dns_get_dig(target, xml_dir, timeout=timeout)
264
+ except Exception as exc: # noqa: BLE001
265
+ return FamilyHostResult(status="error", error_message=str(exc))
266
+ try:
267
+ result = parse_dig_text(dig_text, host=target.host, port=target.port)
268
+ except Exception as exc: # noqa: BLE001
269
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
270
+
271
+ cmd = f"dig @{target.host} -p {target.port} google.com A +time=3 +tries=1"
272
+ confirmed, manual = dns_evaluate(result)
273
+ findings = [(f, dig_text, cmd) for f in confirmed]
274
+ manual_checks = [(f, dig_text, cmd) for f in manual]
275
+ raw_artifacts = [("dig", dig_text)]
276
+
277
+ if not findings and not manual_checks:
278
+ return FamilyHostResult(status="safe", safe_raw_text=dig_text, safe_command=cmd, raw_artifacts=raw_artifacts)
279
+ return FamilyHostResult(
280
+ status="vulnerable" if findings else "safe", findings=findings, manual_checks=manual_checks,
281
+ raw_artifacts=raw_artifacts,
282
+ )
283
+
284
+
285
+ def run_snmp_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
286
+ try:
287
+ text, cmd = snmp_get_onesixtyone(target, xml_dir, timeout=timeout)
288
+ except Exception as exc: # noqa: BLE001
289
+ return FamilyHostResult(status="error", error_message=str(exc))
290
+ try:
291
+ result = parse_onesixtyone_text(text, host=target.host, port=target.port)
292
+ except Exception as exc: # noqa: BLE001
293
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
294
+
295
+ confirmed, manual = snmp_evaluate(result)
296
+ findings = [(f, text, cmd) for f in confirmed]
297
+ manual_checks = [(f, text, cmd) for f in manual]
298
+ raw_artifacts = [("onesixtyone", text)]
299
+
300
+ if not findings and not manual_checks:
301
+ return FamilyHostResult(status="safe", safe_raw_text=text, safe_command=cmd, raw_artifacts=raw_artifacts)
302
+ return FamilyHostResult(
303
+ status="vulnerable" if findings else "safe", findings=findings, manual_checks=manual_checks,
304
+ raw_artifacts=raw_artifacts,
305
+ )
306
+
307
+
308
+ def run_ftp_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
309
+ try:
310
+ text = ftp_get_ftp(target, xml_dir, timeout=timeout)
311
+ except Exception as exc: # noqa: BLE001
312
+ return FamilyHostResult(status="error", error_message=str(exc))
313
+ try:
314
+ result = parse_ftp_text(text, host=target.host, port=target.port)
315
+ except Exception as exc: # noqa: BLE001
316
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
317
+
318
+ cmd = f"ftp -inv {target.host} {target.port} # scripted: user anonymous / ls / quit"
319
+ confirmed, manual = ftp_evaluate(result)
320
+ findings = [(f, text, cmd) for f in confirmed]
321
+ manual_checks = [(f, text, cmd) for f in manual]
322
+ raw_artifacts = [("ftp_session", text)]
323
+
324
+ if not findings and not manual_checks:
325
+ return FamilyHostResult(status="safe", safe_raw_text=text, safe_command=cmd, raw_artifacts=raw_artifacts)
326
+ return FamilyHostResult(
327
+ status="vulnerable" if findings else "safe", findings=findings, manual_checks=manual_checks,
328
+ raw_artifacts=raw_artifacts,
329
+ )
330
+
331
+
332
+ def run_mssql_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
333
+ try:
334
+ text = mssql_get_nmap(target, xml_dir, timeout=timeout)
335
+ except Exception as exc: # noqa: BLE001
336
+ return FamilyHostResult(status="error", error_message=str(exc))
337
+ try:
338
+ result = parse_mssql_text(text, host=target.host, port=target.port)
339
+ except Exception as exc: # noqa: BLE001
340
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
341
+
342
+ cmd = f"nmap -p {target.port} --script ms-sql-info {target.host}"
343
+ confirmed, manual = mssql_evaluate(result)
344
+ findings = [(f, text, cmd) for f in confirmed]
345
+ manual_checks = [(f, text, cmd) for f in manual]
346
+ raw_artifacts = [("mssql_nmap", text)]
347
+
348
+ if not findings and not manual_checks:
349
+ return FamilyHostResult(status="safe", safe_raw_text=text, safe_command=cmd, raw_artifacts=raw_artifacts)
350
+ return FamilyHostResult(
351
+ status="vulnerable" if findings else "safe", findings=findings, manual_checks=manual_checks,
352
+ raw_artifacts=raw_artifacts,
353
+ )
354
+
355
+
356
+ def run_http_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
357
+ marker = http_marker_for(target)
358
+ try:
359
+ version_text = http_get_version(target, xml_dir, timeout=timeout)
360
+ host_header_text = http_get_host_header(target, xml_dir, timeout=timeout)
361
+ trace_text = http_get_trace(target, xml_dir, marker, timeout=timeout)
362
+ except Exception as exc: # noqa: BLE001
363
+ return FamilyHostResult(status="error", error_message=str(exc))
364
+
365
+ try:
366
+ result = parse_http_result(version_text, host_header_text, trace_text, host=target.host, port=target.port)
367
+ except Exception as exc: # noqa: BLE001
368
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
369
+
370
+ version_cmd = f"curl -sS -i http://{target.host}:{target.port}/"
371
+ host_header_cmd = f"curl -sS -i -H \"Host: bing.com\" http://{target.host}:{target.port}/"
372
+ trace_cmd = f"curl -sS -i -X TRACE -H \"X-PT-XST-Marker: {marker}\" http://{target.host}:{target.port}/"
373
+
374
+ text_and_cmd_by_rule = {
375
+ "http_version_disclosure": (version_text, version_cmd),
376
+ "http_host_header_injection": (host_header_text, host_header_cmd),
377
+ "http_xst": (trace_text, trace_cmd),
378
+ }
379
+
380
+ confirmed, manual = http_evaluate(result)
381
+ findings = [(f, *text_and_cmd_by_rule[f.rule_id]) for f in confirmed]
382
+ raw_artifacts = [
383
+ ("http_version", version_text), ("http_hostheader", host_header_text), ("http_trace", trace_text),
384
+ ]
385
+
386
+ if not findings:
387
+ safe_text = (
388
+ "$ " + version_cmd + "\n" + version_text
389
+ + "\n\n$ " + host_header_cmd + "\n" + host_header_text
390
+ + "\n\n$ " + trace_cmd + "\n" + trace_text
391
+ )
392
+ return FamilyHostResult(
393
+ status="safe", safe_raw_text=safe_text, safe_command=version_cmd, raw_artifacts=raw_artifacts,
394
+ )
395
+ return FamilyHostResult(status="vulnerable", findings=findings, raw_artifacts=raw_artifacts)
396
+
397
+
398
+ def run_dirsearch_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
399
+ try:
400
+ text, cmd = get_dirsearch_text(target, xml_dir, timeout=timeout)
401
+ except Exception as exc: # noqa: BLE001
402
+ return FamilyHostResult(status="error", error_message=str(exc))
403
+ try:
404
+ result = parse_dirsearch_text(text, host=target.host, port=target.port)
405
+ except Exception as exc: # noqa: BLE001
406
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
407
+
408
+ confirmed, manual = dirsearch_evaluate(result)
409
+ findings = [(f, text, cmd) for f in confirmed]
410
+ raw_artifacts = [("dirsearch", text)]
411
+
412
+ if not findings:
413
+ return FamilyHostResult(status="safe", safe_raw_text=text, safe_command=cmd, raw_artifacts=raw_artifacts)
414
+ return FamilyHostResult(status="vulnerable", findings=findings, raw_artifacts=raw_artifacts)
415
+
416
+
417
+ def run_cors_family(target: Target, xml_dir: Optional[str], timeout: int) -> FamilyHostResult:
418
+ try:
419
+ text, cmd = get_cors_text(target, xml_dir, timeout=timeout)
420
+ except Exception as exc: # noqa: BLE001
421
+ return FamilyHostResult(status="error", error_message=str(exc))
422
+ try:
423
+ result = parse_cors_text(text, host=target.host, port=target.port, origin_sent=EVIL_ORIGIN)
424
+ except Exception as exc: # noqa: BLE001
425
+ return FamilyHostResult(status="error", error_message=f"parse error: {exc}")
426
+
427
+ confirmed, manual = cors_evaluate(result)
428
+ findings = [(f, text, cmd) for f in confirmed]
429
+ raw_artifacts = [("cors_probe", text)]
430
+
431
+ if not findings:
432
+ return FamilyHostResult(status="safe", safe_raw_text=text, safe_command=cmd, raw_artifacts=raw_artifacts)
433
+ return FamilyHostResult(status="vulnerable", findings=findings, raw_artifacts=raw_artifacts)
434
+
435
+
436
+ # Each family: (name, output_folder, gate_port_or_None_for_own_port, run_function)
437
+ # gate_port=None means "gate on the target's own .port field" (e.g. TLS,
438
+ # which defaults to 443 but can be overridden per-target via host:port).
439
+ # gate_port="udp:<port>" means "no TCP pre-gate at all -- the real UDP
440
+ # protocol probe on that fixed port IS the gate" (see cli.py); used for
441
+ # families whose service has no meaningful TCP presence to "nc -z" (DNS,
442
+ # SNMP). gate_port="prompt" means there's no fixed port at all -- the user
443
+ # supplies it (one or several, comma-separated) only after choosing this
444
+ # mode; cli.py's _resolve_prompt_gated_ports() handles that deferred gating
445
+ # instead of the normal unified up-front port scan.
446
+ # render_poc (default True when absent) -- False means cli.py never renders
447
+ # a POC screenshot for this family's findings, only the .txt evidence file;
448
+ # used for DIRSEARCH, whose "Found" list is what the client wants to see,
449
+ # not a terminal screenshot.
450
+ FAMILIES = [
451
+ {"name": "tls", "folder": "SSL_Scan", "gate_port": None, "run": run_tls_family},
452
+ {"name": "ssh", "folder": "SSH", "gate_port": 22, "run": run_ssh_family},
453
+ {"name": "smb", "folder": "SMB", "gate_port": 445, "run": run_smb_family},
454
+ {"name": "ldap", "folder": "LDAP", "gate_port": 389, "run": run_ldap_family},
455
+ {"name": "rpc", "folder": "RPC", "gate_port": 445, "run": run_rpc_family},
456
+ {"name": "dns", "folder": "DNS", "gate_port": "udp:53", "run": run_dns_family},
457
+ {"name": "mssql", "folder": "MSSQL", "gate_port": 1433, "run": run_mssql_family},
458
+ {"name": "snmp", "folder": "SNMP", "gate_port": "udp:161", "run": run_snmp_family},
459
+ {"name": "ftp", "folder": "FTP", "gate_port": 21, "run": run_ftp_family},
460
+ {"name": "http", "folder": "HTTP", "gate_port": 80, "run": run_http_family},
461
+ {"name": "dirsearch", "folder": "Dirsearch", "gate_port": "prompt", "render_poc": False,
462
+ "deferred_desc": "directory/file brute-force (pick port(s) after selecting)",
463
+ "run": run_dirsearch_family},
464
+ {"name": "cors", "folder": "CORS", "gate_port": "prompt", "render_poc": False,
465
+ "deferred_desc": "CORS misconfiguration check (pick port(s) after selecting)",
466
+ "run": run_cors_family},
467
+ ]
@@ -0,0 +1,28 @@
1
+ """Parses a real `ftp` client session transcript for the anonymous-access check."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ _LOGIN_SUCCESS_RE = re.compile(r"^230\b", re.MULTILINE)
9
+ _LOGIN_FAILURE_RE = re.compile(r"^530\b", re.MULTILINE)
10
+
11
+
12
+ @dataclass
13
+ class FtpResult:
14
+ host: str
15
+ port: int
16
+ raw_text: str
17
+ anonymous_login_allowed: Optional[bool] = None # None = inconclusive
18
+
19
+
20
+ def parse_ftp_text(raw_text: str, host: str, port: int) -> FtpResult:
21
+ result = FtpResult(host=host, port=port, raw_text=raw_text)
22
+ if _LOGIN_SUCCESS_RE.search(raw_text):
23
+ result.anonymous_login_allowed = True
24
+ elif _LOGIN_FAILURE_RE.search(raw_text) or "Login failed" in raw_text or "OOPS:" in raw_text:
25
+ result.anonymous_login_allowed = False
26
+ else:
27
+ result.anonymous_login_allowed = None
28
+ return result
@@ -0,0 +1,56 @@
1
+ """Rule engine for the FTP family: Anonymous FTP Access (#21)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import List, Optional, Tuple
6
+
7
+ from netscraper.core.rules import Finding
8
+ from netscraper.core.ftp_parser import FtpResult
9
+
10
+
11
+ def check_anonymous_ftp(result: FtpResult) -> Optional[Finding]:
12
+ if result.anonymous_login_allowed is not True:
13
+ return None
14
+ return Finding(
15
+ rule_id="ftp_anonymous_access",
16
+ label="Anonymous FTP Access",
17
+ folder_name="Anonymous_FTP_Access",
18
+ severity="high",
19
+ evidence_lines=[
20
+ f"Host: {result.host}:{result.port}",
21
+ "A real `ftp` client session logged in as user 'anonymous' and received '230 Login successful.'.",
22
+ "--> Anonymous FTP allows unauthenticated access to whatever this server shares -- at minimum "
23
+ "directory/file listing, and file download unless further restricted.",
24
+ ],
25
+ highlight_regex=re.compile(r"^230\b", re.MULTILINE),
26
+ )
27
+
28
+
29
+ def check_manual(result: FtpResult) -> Optional[Finding]:
30
+ if result.anonymous_login_allowed is not None:
31
+ return None
32
+ return Finding(
33
+ rule_id="ftp_anonymous_unknown",
34
+ label="Anonymous FTP Access",
35
+ folder_name="Needs_Manual_Verification/Anonymous_FTP",
36
+ severity="medium",
37
+ evidence_lines=[
38
+ f"Host: {result.host}:{result.port}",
39
+ "The scripted ftp session didn't return a recognized 230/530 login result (unusual banner, "
40
+ "connection reset mid-session, or a non-standard FTP daemon).",
41
+ "--> Confirm manually, e.g. `ftp <host>` and log in as 'anonymous'.",
42
+ ],
43
+ highlight_regex=None,
44
+ )
45
+
46
+
47
+ def evaluate(result: FtpResult) -> Tuple[List[Finding], List[Finding]]:
48
+ confirmed = []
49
+ manual = []
50
+ f = check_anonymous_ftp(result)
51
+ if f:
52
+ confirmed.append(f)
53
+ f = check_manual(result)
54
+ if f:
55
+ manual.append(f)
56
+ return confirmed, manual
@@ -0,0 +1,63 @@
1
+ """
2
+ Runs a real, scripted `ftp` client session against a target: log in as
3
+ "anonymous" with a conventional anonymous-FTP identification string as the
4
+ password, then `ls` (so a successful login also shows a real directory
5
+ listing as evidence), then quit. The server's own real "230 Login
6
+ successful." vs "530 Login incorrect." response is the signal -- validated
7
+ against two real local vsftpd instances (anonymous enabled vs. disabled).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ import subprocess
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from netscraper.core.targets import Target
17
+
18
+ _LOGIN_SCRIPT = "user anonymous anonymous@example.com\nls\nquit\n"
19
+
20
+
21
+ class FtpToolNotFound(RuntimeError):
22
+ pass
23
+
24
+
25
+ class FtpScanError(RuntimeError):
26
+ def __init__(self, target: Target, message: str):
27
+ super().__init__(message)
28
+ self.target = target
29
+
30
+
31
+ def _require(binary: str) -> str:
32
+ path = shutil.which(binary)
33
+ if not path:
34
+ raise FtpToolNotFound(f"'{binary}' is not installed or not on PATH (apt install ftp).")
35
+ return path
36
+
37
+
38
+ def run_live_text(target: Target, timeout: int = 20) -> str:
39
+ binary = _require("ftp")
40
+ cmd = [binary, "-inv", target.host, str(target.port)]
41
+ try:
42
+ proc = subprocess.run(cmd, input=_LOGIN_SCRIPT, capture_output=True, text=True, timeout=timeout)
43
+ except subprocess.TimeoutExpired as exc:
44
+ raise FtpScanError(target, f"ftp timed out after {timeout}s") from exc
45
+ text = (proc.stdout or "") + (proc.stderr or "")
46
+ if not text.strip():
47
+ raise FtpScanError(target, "ftp produced no output")
48
+ return text
49
+
50
+
51
+ def run_replay_text(target: Target, xml_dir: str) -> str:
52
+ candidate = Path(xml_dir) / f"{target.label}_ftp.txt"
53
+ if not candidate.exists():
54
+ candidate = Path(xml_dir) / f"{target.host}_ftp.txt"
55
+ if not candidate.exists():
56
+ raise FtpScanError(target, f"No captured ftp session output found for {target.host} in {xml_dir}")
57
+ return candidate.read_text(encoding="utf-8")
58
+
59
+
60
+ def get_ftp_text(target: Target, xml_dir: Optional[str], timeout: int = 20) -> str:
61
+ if xml_dir:
62
+ return run_replay_text(target, xml_dir)
63
+ return run_live_text(target, timeout=timeout)
@@ -0,0 +1,59 @@
1
+ """Parses the three real curl captures for the HTTP family."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Optional
7
+
8
+ _STATUS_RE = re.compile(r"^HTTP/[\d.]+\s+(\d{3})", re.MULTILINE)
9
+ _SERVER_HEADER_RE = re.compile(r"^Server:\s*(.+?)\s*$", re.MULTILINE | re.IGNORECASE)
10
+ _VERSION_NUMBER_RE = re.compile(r"\d+\.\d+")
11
+ _LOCATION_HEADER_RE = re.compile(r"^Location:\s*(.+?)\s*$", re.MULTILINE | re.IGNORECASE)
12
+
13
+ XST_MARKER_HEADER_NAME = "X-PT-XST-Marker"
14
+
15
+
16
+ @dataclass
17
+ class HttpResult:
18
+ host: str
19
+ port: int
20
+ version_text: str
21
+ host_header_text: str
22
+ trace_text: str
23
+ server_header: Optional[str] = None
24
+ version_disclosed: bool = False
25
+ host_header_status: Optional[int] = None
26
+ host_header_location: Optional[str] = None
27
+ host_header_reflected: bool = False
28
+ trace_status: Optional[int] = None
29
+ trace_reflected: bool = False
30
+
31
+
32
+ def parse_http_result(version_text: str, host_header_text: str, trace_text: str, host: str, port: int) -> HttpResult:
33
+ result = HttpResult(
34
+ host=host, port=port,
35
+ version_text=version_text, host_header_text=host_header_text, trace_text=trace_text,
36
+ )
37
+
38
+ m = _SERVER_HEADER_RE.search(version_text)
39
+ if m:
40
+ result.server_header = m.group(1).strip()
41
+ result.version_disclosed = bool(_VERSION_NUMBER_RE.search(result.server_header))
42
+
43
+ sm = _STATUS_RE.search(host_header_text)
44
+ if sm:
45
+ result.host_header_status = int(sm.group(1))
46
+ lm = _LOCATION_HEADER_RE.search(host_header_text)
47
+ if lm:
48
+ result.host_header_location = lm.group(1).strip()
49
+ result.host_header_reflected = (
50
+ result.host_header_status is not None and 300 <= result.host_header_status < 400
51
+ and "bing.com" in result.host_header_location.lower()
52
+ )
53
+
54
+ tm = _STATUS_RE.search(trace_text)
55
+ if tm:
56
+ result.trace_status = int(tm.group(1))
57
+ result.trace_reflected = XST_MARKER_HEADER_NAME.lower() in trace_text.lower()
58
+
59
+ return result