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,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
|