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,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parses a real `curl -i` response for the CORS check: pulls out
|
|
3
|
+
Access-Control-Allow-Origin / Access-Control-Allow-Credentials and decides
|
|
4
|
+
whether the server reflected our deliberately bogus Origin (see
|
|
5
|
+
cors_runner.EVIL_ORIGIN) back verbatim. That's the one signal this check is
|
|
6
|
+
judged on -- a static "*" wildcard, a different real allow-listed origin,
|
|
7
|
+
or no CORS header at all are all left as NOT reflected, so none of them
|
|
8
|
+
gets misclassified as a reflection vulnerability.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
_ACAO_RE = re.compile(r"^Access-Control-Allow-Origin:\s*(.+?)\s*$", re.IGNORECASE | re.MULTILINE)
|
|
17
|
+
_ACAC_RE = re.compile(r"^Access-Control-Allow-Credentials:\s*(.+?)\s*$", re.IGNORECASE | re.MULTILINE)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CorsResult:
|
|
22
|
+
host: str
|
|
23
|
+
port: int
|
|
24
|
+
raw_text: str
|
|
25
|
+
origin_sent: str
|
|
26
|
+
acao: Optional[str] = None
|
|
27
|
+
acac: Optional[str] = None
|
|
28
|
+
reflected: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_cors_text(raw_text: str, host: str, port: int, origin_sent: str) -> CorsResult:
|
|
32
|
+
acao_match = _ACAO_RE.search(raw_text)
|
|
33
|
+
acac_match = _ACAC_RE.search(raw_text)
|
|
34
|
+
acao = acao_match.group(1).strip() if acao_match else None
|
|
35
|
+
acac = acac_match.group(1).strip() if acac_match else None
|
|
36
|
+
|
|
37
|
+
# An exact echo of the Origin we sent (ignoring a trailing slash / case)
|
|
38
|
+
# is what proves dynamic reflection -- a static "*" wildcard, or any
|
|
39
|
+
# other real value, doesn't match and is correctly left as Safe.
|
|
40
|
+
reflected = bool(acao) and acao.rstrip("/").lower() == origin_sent.rstrip("/").lower()
|
|
41
|
+
|
|
42
|
+
return CorsResult(
|
|
43
|
+
host=host, port=port, raw_text=raw_text, origin_sent=origin_sent,
|
|
44
|
+
acao=acao, acac=acac, reflected=reflected,
|
|
45
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rule engine for the CORS check: exactly one deterministic rule -- did the
|
|
3
|
+
server reflect our deliberately bogus Origin (https://evil.com by default,
|
|
4
|
+
see cors_runner.EVIL_ORIGIN) back verbatim in its own
|
|
5
|
+
Access-Control-Allow-Origin response header? A static "*" wildcard, a
|
|
6
|
+
different real allow-listed origin, or no CORS header at all are all Safe
|
|
7
|
+
-- only an exact echo of the attacker-controlled value we sent proves the
|
|
8
|
+
server isn't validating Origin against a real allow-list, which is exactly
|
|
9
|
+
what cors_parser.py's `reflected` flag already captures.
|
|
10
|
+
|
|
11
|
+
No manual-verification bucket here -- like Host Header Injection / XST,
|
|
12
|
+
this is a binary yes/no read of the server's own real response, nothing
|
|
13
|
+
fuzzy to ask a human to confirm. No POC screenshot is ever rendered for
|
|
14
|
+
this check either (see cli.py's render_poc=False for the cors family) --
|
|
15
|
+
instead, the evidence below always includes the exact curl command a
|
|
16
|
+
tester can re-run against the real host to build their own manual PoC.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import List, Tuple
|
|
21
|
+
|
|
22
|
+
from netscraper.core.rules import Finding
|
|
23
|
+
from netscraper.core.cors_parser import CorsResult
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def evaluate(result: CorsResult) -> Tuple[List[Finding], List[Finding]]:
|
|
27
|
+
if not result.reflected:
|
|
28
|
+
return [], []
|
|
29
|
+
|
|
30
|
+
creds = (result.acac or "").strip().lower() == "true"
|
|
31
|
+
scheme = "https" if result.port in (443, 8443, 9443) else "http"
|
|
32
|
+
poc_cmd = f'curl -sS -i -H "Origin: {result.origin_sent}" {scheme}://{result.host}:{result.port}/'
|
|
33
|
+
|
|
34
|
+
if creds:
|
|
35
|
+
impact = (
|
|
36
|
+
"including credentialed requests (cookies/session), since Access-Control-Allow-Credentials "
|
|
37
|
+
"is also true -- an attacker-controlled page can make a victim's browser send authenticated "
|
|
38
|
+
"requests here and read the response back. This is a critical, often account-takeover-class "
|
|
39
|
+
"CORS misconfiguration."
|
|
40
|
+
)
|
|
41
|
+
else:
|
|
42
|
+
impact = (
|
|
43
|
+
"for non-credentialed requests (Access-Control-Allow-Credentials wasn't also true, so "
|
|
44
|
+
"cookie/session-authenticated responses aren't included in what a cross-origin page can "
|
|
45
|
+
"read) -- still a real misconfiguration worth fixing."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
finding = Finding(
|
|
49
|
+
rule_id="cors_origin_reflection",
|
|
50
|
+
label="CORS Misconfiguration -- Arbitrary Origin Reflected",
|
|
51
|
+
folder_name="CORS_Misconfiguration",
|
|
52
|
+
severity="high" if creds else "medium",
|
|
53
|
+
evidence_lines=[
|
|
54
|
+
f"Host: {result.host}:{result.port}",
|
|
55
|
+
f"Origin sent: {result.origin_sent}",
|
|
56
|
+
f"Access-Control-Allow-Origin returned: {result.acao}",
|
|
57
|
+
f"Access-Control-Allow-Credentials returned: {result.acac or '(not present)'}",
|
|
58
|
+
"",
|
|
59
|
+
f"--> The server reflects an arbitrary, attacker-controlled Origin back verbatim instead "
|
|
60
|
+
f"of validating it against a real allow-list, {impact}",
|
|
61
|
+
"",
|
|
62
|
+
"Manual verification / PoC (run this against the real host yourself to confirm and capture "
|
|
63
|
+
"your own screenshot for the report if you need one):",
|
|
64
|
+
f" {poc_cmd}",
|
|
65
|
+
],
|
|
66
|
+
# No POC screenshot is ever rendered for this check -- the curl
|
|
67
|
+
# command above, already filled in with the real host/port, is the
|
|
68
|
+
# PoC a tester runs by hand.
|
|
69
|
+
highlight_regex=None,
|
|
70
|
+
)
|
|
71
|
+
return [finding], []
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runs a real `curl` request against a target with a deliberately
|
|
3
|
+
attacker-style Origin header (see EVIL_ORIGIN below) and captures the raw
|
|
4
|
+
response headers. The CORS check itself is judged entirely on whether the
|
|
5
|
+
server's own Access-Control-Allow-Origin response header reflects that
|
|
6
|
+
exact value back (see cors_parser.py / cors_rules.py) -- this module's only
|
|
7
|
+
job is to make the real request and hand back the real response, untouched.
|
|
8
|
+
|
|
9
|
+
Like DIRSEARCH, CORS has no fixed port -- the user chooses it (single or
|
|
10
|
+
comma-separated) when selecting this mode; by the time a Target reaches
|
|
11
|
+
this module, target.port is already one concrete, real port to probe.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from netscraper.core.targets import Target
|
|
21
|
+
|
|
22
|
+
# Sent as the request's Origin header on every probe. It's a domain that
|
|
23
|
+
# could never legitimately sit on any real allow-list, so the server
|
|
24
|
+
# reflecting it back verbatim in Access-Control-Allow-Origin proves it
|
|
25
|
+
# isn't validating Origin against a real allow-list at all -- it's just
|
|
26
|
+
# echoing whatever it was sent.
|
|
27
|
+
EVIL_ORIGIN = "https://evil.com"
|
|
28
|
+
|
|
29
|
+
# Same http-vs-https heuristic used by the DIRSEARCH check (see
|
|
30
|
+
# dirsearch_runner.py) -- there's no cheap way to know for sure without a
|
|
31
|
+
# real TLS handshake first, but this matches the overwhelming majority of
|
|
32
|
+
# real-world setups; a wrong guess just fails to connect (an ERROR row,
|
|
33
|
+
# never a false "vulnerable" result).
|
|
34
|
+
_HTTPS_PORTS = {443, 8443, 9443}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CurlNotFound(RuntimeError):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CorsScanError(RuntimeError):
|
|
42
|
+
def __init__(self, target: Target, message: str):
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.target = target
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def scheme_for(target: Target) -> str:
|
|
48
|
+
return "https" if target.port in _HTTPS_PORTS else "http"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _command_parts(target: Target, timeout: int) -> List[str]:
|
|
52
|
+
url = f"{scheme_for(target)}://{target.host}:{target.port}/"
|
|
53
|
+
return [
|
|
54
|
+
"curl", "-sS", "-i", "--max-time", str(timeout),
|
|
55
|
+
"-H", f"Origin: {EVIL_ORIGIN}",
|
|
56
|
+
url,
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def command_display(target: Target, timeout: int) -> str:
|
|
61
|
+
"""The exact command shown in evidence -- same string whether this ran
|
|
62
|
+
live or is being replayed, and usable directly as a manual PoC."""
|
|
63
|
+
return " ".join(_command_parts(target, timeout))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _require(binary: str) -> str:
|
|
67
|
+
path = shutil.which(binary)
|
|
68
|
+
if not path:
|
|
69
|
+
raise CurlNotFound(f"'{binary}' is not installed or not on PATH.")
|
|
70
|
+
return path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run_live_text(target: Target, timeout: int = 15) -> str:
|
|
74
|
+
binary = _require("curl")
|
|
75
|
+
cmd = _command_parts(target, timeout)
|
|
76
|
+
cmd[0] = binary
|
|
77
|
+
try:
|
|
78
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
79
|
+
except subprocess.TimeoutExpired as exc:
|
|
80
|
+
raise CorsScanError(target, f"curl timed out after {timeout}s") from exc
|
|
81
|
+
text = (proc.stdout or "") + (proc.stderr or "")
|
|
82
|
+
if not text.strip():
|
|
83
|
+
raise CorsScanError(target, "curl produced no output")
|
|
84
|
+
return text
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_replay_text(target: Target, xml_dir: str) -> str:
|
|
88
|
+
candidate = Path(xml_dir) / f"{target.label}_cors.txt"
|
|
89
|
+
if not candidate.exists():
|
|
90
|
+
candidate = Path(xml_dir) / f"{target.host}_cors.txt"
|
|
91
|
+
if not candidate.exists():
|
|
92
|
+
raise CorsScanError(target, f"No captured CORS probe output found for {target.host} in {xml_dir}")
|
|
93
|
+
return candidate.read_text(encoding="utf-8")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_cors_text(target: Target, xml_dir: Optional[str], timeout: int = 15) -> Tuple[str, str]:
|
|
97
|
+
cmd = command_display(target, timeout)
|
|
98
|
+
if xml_dir:
|
|
99
|
+
return run_replay_text(target, xml_dir), cmd
|
|
100
|
+
return run_live_text(target, timeout=timeout), cmd
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parses real `dirsearch` output (quiet mode) down to just the found-path
|
|
3
|
+
lines a tester actually cares about. dirsearch is already asked (via its own
|
|
4
|
+
-i/--exclude-sizes CLI flags -- see dirsearch_runner.py) to only print
|
|
5
|
+
status 200/301/302 and skip 0-byte responses, but this module re-verifies
|
|
6
|
+
that against the real captured text rather than trusting the CLI flags
|
|
7
|
+
blindly -- the same principle every other check in this toolkit follows
|
|
8
|
+
(e.g. sslscan's XML is parsed and re-evaluated, never assumed correct).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import List
|
|
15
|
+
|
|
16
|
+
_WANTED_CODES = {"200", "301", "302"}
|
|
17
|
+
|
|
18
|
+
# Tolerant of dirsearch's real output layout across versions -- e.g.
|
|
19
|
+
# "200 - 318B - /robots.txt" or "[10:11:12] 301 - 0B - /images ->
|
|
20
|
+
# /images/". The size token is always a number immediately followed by a
|
|
21
|
+
# unit ("B", "KB", "MB", ...).
|
|
22
|
+
_LINE_RE = re.compile(
|
|
23
|
+
r"(?P<code>\d{3})\s*-?\s*(?P<size>[\d.]+\s?[KMGT]?B)\s*-\s*(?P<path>\S+)"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class DirsearchResult:
|
|
29
|
+
host: str
|
|
30
|
+
port: int
|
|
31
|
+
raw_text: str
|
|
32
|
+
found_lines: List[str] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_dirsearch_text(raw_text: str, host: str, port: int) -> DirsearchResult:
|
|
36
|
+
found_lines: List[str] = []
|
|
37
|
+
for line in raw_text.splitlines():
|
|
38
|
+
m = _LINE_RE.search(line)
|
|
39
|
+
if not m:
|
|
40
|
+
continue
|
|
41
|
+
code = m.group("code")
|
|
42
|
+
if code not in _WANTED_CODES:
|
|
43
|
+
continue
|
|
44
|
+
size = m.group("size").replace(" ", "").upper()
|
|
45
|
+
if size == "0B":
|
|
46
|
+
continue
|
|
47
|
+
found_lines.append(line.strip())
|
|
48
|
+
return DirsearchResult(host=host, port=port, raw_text=raw_text, found_lines=found_lines)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rule engine for the DIRSEARCH check. There's really only one "rule" here:
|
|
3
|
+
did dirsearch find anything worth a tester looking at (a real 200/301/302 on
|
|
4
|
+
a real path, never a 0-byte noise hit)? If yes, that's one finding carrying
|
|
5
|
+
every found line as its evidence; if no, it's Safe -- exactly the same
|
|
6
|
+
Vulnerable/Safe shape every other check in this toolkit uses, so it needs no
|
|
7
|
+
special-case handling in cli.py's summary/Excel logic.
|
|
8
|
+
|
|
9
|
+
No manual-verification bucket here -- dirsearch's own real HTTP status code
|
|
10
|
+
is either a real hit or it isn't, nothing fuzzy to ask a human to confirm.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import List, Tuple
|
|
15
|
+
|
|
16
|
+
from netscraper.core.rules import Finding
|
|
17
|
+
from netscraper.core.dirsearch_parser import DirsearchResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def evaluate(result: DirsearchResult) -> Tuple[List[Finding], List[Finding]]:
|
|
21
|
+
if not result.found_lines:
|
|
22
|
+
return [], []
|
|
23
|
+
|
|
24
|
+
finding = Finding(
|
|
25
|
+
rule_id="dirsearch_paths_found",
|
|
26
|
+
label=f"Dirsearch -- {len(result.found_lines)} Path(s)/File(s) Found",
|
|
27
|
+
folder_name="Found_Paths",
|
|
28
|
+
severity="medium",
|
|
29
|
+
evidence_lines=(
|
|
30
|
+
[f"Host: {result.host}:{result.port}", ""]
|
|
31
|
+
+ result.found_lines
|
|
32
|
+
),
|
|
33
|
+
# No screenshot is ever rendered for this check (see cli.py's
|
|
34
|
+
# render_poc=False for the dirsearch family), so there's nothing to
|
|
35
|
+
# highlight a line for -- the saved evidence .txt above is the
|
|
36
|
+
# "Found results" the client asked to see.
|
|
37
|
+
highlight_regex=None,
|
|
38
|
+
)
|
|
39
|
+
return [finding], []
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runs the real `dirsearch` tool (directory/file brute-forcer) against a
|
|
3
|
+
single host:port, asking dirsearch itself to already narrow its own output
|
|
4
|
+
down to exactly what a tester wants to see -- quiet mode (no banner/progress
|
|
5
|
+
noise), only status codes 200/301/302, and no 0-byte responses -- so the
|
|
6
|
+
captured text is close to "found results" already. The parser layer
|
|
7
|
+
(dirsearch_parser.py) still re-verifies that filtering itself against the
|
|
8
|
+
real captured output rather than trusting the CLI flags blindly, same as
|
|
9
|
+
every other check in this toolkit.
|
|
10
|
+
|
|
11
|
+
Unlike every other check's port, DIRSEARCH's port isn't fixed -- the user
|
|
12
|
+
chooses it (single or comma-separated) when they select this mode. See
|
|
13
|
+
cli.py's _resolve_prompt_gated_ports() for where that port list actually comes
|
|
14
|
+
from; by the time a Target reaches this module, target.port is already one
|
|
15
|
+
concrete, real port to brute-force.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
from netscraper.core.targets import Target
|
|
25
|
+
|
|
26
|
+
# 443 and the common alternate HTTPS ports are probed over TLS; everything
|
|
27
|
+
# else is treated as plain HTTP. This is a heuristic (there's no cheap way
|
|
28
|
+
# to know for sure without a real TLS handshake first), but it matches the
|
|
29
|
+
# overwhelming majority of real-world setups. If it guesses wrong, dirsearch
|
|
30
|
+
# just fails to connect -- reported as an ERROR row like any other
|
|
31
|
+
# unreachable target, never a false "found" result.
|
|
32
|
+
_HTTPS_PORTS = {443, 8443, 9443}
|
|
33
|
+
|
|
34
|
+
# Per-HTTP-request timeout dirsearch itself enforces (its own --timeout
|
|
35
|
+
# flag) -- deliberately kept short and fixed, separate from the overall
|
|
36
|
+
# wall-clock budget for the whole brute-force run (see get_dirsearch_text's
|
|
37
|
+
# own `timeout` argument, which is the subprocess-level cutoff).
|
|
38
|
+
_REQUEST_TIMEOUT = "10"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DirsearchNotFound(RuntimeError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DirsearchScanError(RuntimeError):
|
|
46
|
+
def __init__(self, target: Target, message: str):
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.target = target
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def scheme_for(target: Target) -> str:
|
|
52
|
+
return "https" if target.port in _HTTPS_PORTS else "http"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _command_parts(target: Target) -> List[str]:
|
|
56
|
+
url = f"{scheme_for(target)}://{target.host}:{target.port}/"
|
|
57
|
+
return [
|
|
58
|
+
"dirsearch", "-u", url,
|
|
59
|
+
"-q", # quiet mode -- suppress banner/progress noise
|
|
60
|
+
"-i", "200,301,302", # only status codes worth a tester's time
|
|
61
|
+
"--exclude-sizes", "0B", # a 0-byte "200" is almost always noise
|
|
62
|
+
"--timeout", _REQUEST_TIMEOUT,
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def command_display(target: Target) -> str:
|
|
67
|
+
"""The exact command shown in evidence, without needing dirsearch to
|
|
68
|
+
actually be on PATH (used for both live and replay output)."""
|
|
69
|
+
return " ".join(_command_parts(target))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _require_binary() -> str:
|
|
73
|
+
path = shutil.which("dirsearch")
|
|
74
|
+
if not path:
|
|
75
|
+
raise DirsearchNotFound(
|
|
76
|
+
"'dirsearch' is not installed or not on PATH "
|
|
77
|
+
"(pip install dirsearch, or clone https://github.com/maurosoria/dirsearch and add it to PATH)."
|
|
78
|
+
)
|
|
79
|
+
return path
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def run_live_text(target: Target, timeout: int = 300) -> str:
|
|
83
|
+
binary = _require_binary()
|
|
84
|
+
cmd = _command_parts(target)
|
|
85
|
+
cmd[0] = binary
|
|
86
|
+
try:
|
|
87
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
88
|
+
except subprocess.TimeoutExpired as exc:
|
|
89
|
+
raise DirsearchScanError(target, f"dirsearch timed out after {timeout}s") from exc
|
|
90
|
+
text = (proc.stdout or "") + (proc.stderr or "")
|
|
91
|
+
if not text.strip():
|
|
92
|
+
raise DirsearchScanError(target, "dirsearch produced no output")
|
|
93
|
+
return text
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run_replay_text(target: Target, xml_dir: str) -> str:
|
|
97
|
+
candidate = Path(xml_dir) / f"{target.label}_dirsearch.txt"
|
|
98
|
+
if not candidate.exists():
|
|
99
|
+
candidate = Path(xml_dir) / f"{target.host}_dirsearch.txt"
|
|
100
|
+
if not candidate.exists():
|
|
101
|
+
raise DirsearchScanError(target, f"No captured dirsearch output found for {target.host} in {xml_dir}")
|
|
102
|
+
return candidate.read_text(encoding="utf-8")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_dirsearch_text(target: Target, xml_dir: Optional[str], timeout: int = 300) -> Tuple[str, str]:
|
|
106
|
+
cmd = command_display(target)
|
|
107
|
+
if xml_dir:
|
|
108
|
+
return run_replay_text(target, xml_dir), cmd
|
|
109
|
+
return run_live_text(target, timeout=timeout), cmd
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Parses `dig` output for the DNS recursion check."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
_FLAGS_RE = re.compile(r"^;;\s*flags:\s*(?P<flags>[a-z0-9 ]*?);", re.IGNORECASE | re.MULTILINE)
|
|
9
|
+
_TIMEOUT_MARKERS = ("connection timed out", "no servers could be reached", "communications error")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class DnsResult:
|
|
14
|
+
host: str
|
|
15
|
+
port: int
|
|
16
|
+
raw_text: str
|
|
17
|
+
recursion_available: Optional[bool] = None # None = inconclusive (no answer at all)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_dig_text(raw_text: str, host: str, port: int) -> DnsResult:
|
|
21
|
+
result = DnsResult(host=host, port=port, raw_text=raw_text)
|
|
22
|
+
|
|
23
|
+
if any(marker in raw_text.lower() for marker in _TIMEOUT_MARKERS):
|
|
24
|
+
result.recursion_available = None
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
m = _FLAGS_RE.search(raw_text)
|
|
28
|
+
if not m:
|
|
29
|
+
result.recursion_available = None
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
flags = m.group("flags").split()
|
|
33
|
+
result.recursion_available = "ra" in flags
|
|
34
|
+
return result
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Rule engine for the DNS family: DNS Recursion Enabled (#3)."""
|
|
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.dns_parser import DnsResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def check_recursion_enabled(result: DnsResult) -> Optional[Finding]:
|
|
12
|
+
if result.recursion_available is not True:
|
|
13
|
+
return None
|
|
14
|
+
return Finding(
|
|
15
|
+
rule_id="dns_recursion_enabled",
|
|
16
|
+
label="DNS Recursion Enabled",
|
|
17
|
+
folder_name="DNS_Recursion_Enabled",
|
|
18
|
+
severity="medium",
|
|
19
|
+
evidence_lines=[
|
|
20
|
+
f"Host: {result.host}:{result.port}",
|
|
21
|
+
"The DNS response header includes the 'ra' (Recursion Available) flag for an arbitrary, "
|
|
22
|
+
"non-local domain query.",
|
|
23
|
+
"--> An open recursive resolver reachable from untrusted networks is a classic vector for "
|
|
24
|
+
"DNS amplification (DDoS reflection), cache poisoning, and unauthorized reconnaissance.",
|
|
25
|
+
],
|
|
26
|
+
highlight_regex=re.compile(r"^;;\s*flags:.*\bra\b", re.IGNORECASE | re.MULTILINE),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def check_recursion_manual(result: DnsResult) -> Optional[Finding]:
|
|
31
|
+
if result.recursion_available is not None:
|
|
32
|
+
return None
|
|
33
|
+
return Finding(
|
|
34
|
+
rule_id="dns_recursion_unknown",
|
|
35
|
+
label="DNS Recursion Enabled",
|
|
36
|
+
folder_name="Needs_Manual_Verification/DNS_Recursion",
|
|
37
|
+
severity="medium",
|
|
38
|
+
evidence_lines=[
|
|
39
|
+
f"Host: {result.host}:{result.port}",
|
|
40
|
+
"dig did not get a well-formed DNS response (no answer, or a transport-level failure) -- "
|
|
41
|
+
"not enough to call this Safe or Vulnerable.",
|
|
42
|
+
"--> Confirm manually, e.g. `dig @<host> google.com A`.",
|
|
43
|
+
],
|
|
44
|
+
highlight_regex=None,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def evaluate(result: DnsResult) -> Tuple[List[Finding], List[Finding]]:
|
|
49
|
+
confirmed = []
|
|
50
|
+
manual = []
|
|
51
|
+
f = check_recursion_enabled(result)
|
|
52
|
+
if f:
|
|
53
|
+
confirmed.append(f)
|
|
54
|
+
f = check_recursion_manual(result)
|
|
55
|
+
if f:
|
|
56
|
+
manual.append(f)
|
|
57
|
+
return confirmed, manual
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Runs `dig` against a target's DNS service to test for open recursion (#3):
|
|
3
|
+
a resolver that will happily recurse an arbitrary (non-authoritative,
|
|
4
|
+
non-local) query for anyone is a classic amplification/cache-poisoning/
|
|
5
|
+
reconnaissance risk. The real signal is the "ra" (Recursion Available) flag
|
|
6
|
+
in the response header -- exactly what nmap's own `dns-recursion` script
|
|
7
|
+
keys off, and what was validated here against two real local `named`
|
|
8
|
+
instances (recursion enabled/open vs. recursion disabled).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from netscraper.core.targets import Target
|
|
18
|
+
|
|
19
|
+
# A public-ish domain used purely to see how the SERVER'S resolver behaves
|
|
20
|
+
# (an authoritative/local-only server should refuse or answer without "ra";
|
|
21
|
+
# an open recursive resolver will show "ra" whether or not it can actually
|
|
22
|
+
# complete the lookup upstream).
|
|
23
|
+
_PROBE_DOMAIN = "google.com"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DigNotFound(RuntimeError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class DnsScanError(RuntimeError):
|
|
31
|
+
def __init__(self, target: Target, message: str):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.target = target
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _require(binary: str) -> str:
|
|
37
|
+
path = shutil.which(binary)
|
|
38
|
+
if not path:
|
|
39
|
+
raise DigNotFound(f"'{binary}' is not installed or not on PATH (apt install dnsutils).")
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_live_text(target: Target, timeout: int = 10) -> str:
|
|
44
|
+
binary = _require("dig")
|
|
45
|
+
cmd = [binary, f"@{target.host}", "-p", str(target.port), _PROBE_DOMAIN, "A", "+time=3", "+tries=1"]
|
|
46
|
+
try:
|
|
47
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
48
|
+
except subprocess.TimeoutExpired as exc:
|
|
49
|
+
raise DnsScanError(target, f"dig timed out after {timeout}s") from exc
|
|
50
|
+
text = (proc.stdout or "") + (proc.stderr or "")
|
|
51
|
+
if not text.strip():
|
|
52
|
+
raise DnsScanError(target, "dig produced no output")
|
|
53
|
+
return text
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run_replay_text(target: Target, xml_dir: str) -> str:
|
|
57
|
+
candidate = Path(xml_dir) / f"{target.label}_dig.txt"
|
|
58
|
+
if not candidate.exists():
|
|
59
|
+
candidate = Path(xml_dir) / f"{target.host}_dig.txt"
|
|
60
|
+
if not candidate.exists():
|
|
61
|
+
raise DnsScanError(target, f"No captured dig output found for {target.host} in {xml_dir}")
|
|
62
|
+
return candidate.read_text(encoding="utf-8")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_dig_text(target: Target, xml_dir: Optional[str], timeout: int = 10) -> str:
|
|
66
|
+
if xml_dir:
|
|
67
|
+
return run_replay_text(target, xml_dir)
|
|
68
|
+
return run_live_text(target, timeout=timeout)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Builds the final PT_Scan_Report.xlsx for a run, styled to match the
|
|
3
|
+
reference template the client provided: S.No / Vulnerability / Host /
|
|
4
|
+
Status columns, a blue header row, and a red/green Status cell per row
|
|
5
|
+
(Vulnerable = red, Not Vulnerable = green), bold white text throughout,
|
|
6
|
+
thin borders, centered alignment.
|
|
7
|
+
|
|
8
|
+
Only two kinds of rows go in here, by design (matching what the reference
|
|
9
|
+
template's Status column actually supports):
|
|
10
|
+
- One row per CONFIRMED finding -- "Vulnerable", red.
|
|
11
|
+
- One row per (host, check) that came back completely clean -- "Not
|
|
12
|
+
Vulnerable", green.
|
|
13
|
+
N/A (port not open), ERROR (tool failure), and NEEDS MANUAL VERIFICATION
|
|
14
|
+
results are deliberately left out of this file -- they don't fit a binary
|
|
15
|
+
Vulnerable/Not-Vulnerable verdict, and forcing them into one would
|
|
16
|
+
misrepresent an inconclusive result as a real finding. They're still fully
|
|
17
|
+
recorded in summary.csv and in their own POC/evidence files either way.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import List, Tuple
|
|
23
|
+
|
|
24
|
+
from openpyxl import Workbook
|
|
25
|
+
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
|
26
|
+
|
|
27
|
+
_HEADER_FILL = PatternFill(start_color="FF0070C0", end_color="FF0070C0", fill_type="solid")
|
|
28
|
+
_VULNERABLE_FILL = PatternFill(start_color="FFFF0000", end_color="FFFF0000", fill_type="solid")
|
|
29
|
+
_NOT_VULNERABLE_FILL = PatternFill(start_color="FF00B050", end_color="FF00B050", fill_type="solid")
|
|
30
|
+
|
|
31
|
+
_WHITE_BOLD = Font(name="Aptos Narrow", size=12, bold=True, color="FFFFFFFF")
|
|
32
|
+
_PLAIN = Font(name="Aptos Narrow", size=12, bold=False, color="FF000000")
|
|
33
|
+
|
|
34
|
+
_THIN = Side(style="thin")
|
|
35
|
+
_BORDER = Border(top=_THIN, bottom=_THIN, left=_THIN, right=_THIN)
|
|
36
|
+
_CENTER = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
|
37
|
+
|
|
38
|
+
_HEADERS = ["S.No", "Vulnerability", "Host", "Status"]
|
|
39
|
+
|
|
40
|
+
# ReportRow: (vulnerability_label, host_str, status) where status is
|
|
41
|
+
# exactly "Vulnerable" or "Not Vulnerable".
|
|
42
|
+
ReportRow = Tuple[str, str, str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_report(rows: List[ReportRow], output_path: Path) -> None:
|
|
46
|
+
wb = Workbook()
|
|
47
|
+
ws = wb.active
|
|
48
|
+
ws.title = "PT Scan Report"
|
|
49
|
+
|
|
50
|
+
for col_idx, header in enumerate(_HEADERS, start=1):
|
|
51
|
+
cell = ws.cell(row=1, column=col_idx, value=header)
|
|
52
|
+
cell.fill = _HEADER_FILL
|
|
53
|
+
cell.font = _WHITE_BOLD
|
|
54
|
+
cell.alignment = _CENTER
|
|
55
|
+
cell.border = _BORDER
|
|
56
|
+
|
|
57
|
+
for row_idx, (vuln, host_str, status) in enumerate(rows, start=2):
|
|
58
|
+
s_no_cell = ws.cell(row=row_idx, column=1, value=row_idx - 1)
|
|
59
|
+
vuln_cell = ws.cell(row=row_idx, column=2, value=vuln)
|
|
60
|
+
host_cell = ws.cell(row=row_idx, column=3, value=host_str)
|
|
61
|
+
status_cell = ws.cell(row=row_idx, column=4, value=status)
|
|
62
|
+
|
|
63
|
+
for cell in (s_no_cell, vuln_cell, host_cell):
|
|
64
|
+
cell.font = _PLAIN
|
|
65
|
+
cell.alignment = _CENTER
|
|
66
|
+
cell.border = _BORDER
|
|
67
|
+
|
|
68
|
+
status_cell.alignment = _CENTER
|
|
69
|
+
status_cell.border = _BORDER
|
|
70
|
+
if status == "Vulnerable":
|
|
71
|
+
status_cell.fill = _VULNERABLE_FILL
|
|
72
|
+
status_cell.font = _WHITE_BOLD
|
|
73
|
+
else:
|
|
74
|
+
status_cell.fill = _NOT_VULNERABLE_FILL
|
|
75
|
+
status_cell.font = _WHITE_BOLD
|
|
76
|
+
|
|
77
|
+
ws.column_dimensions["A"].width = 8
|
|
78
|
+
ws.column_dimensions["B"].width = 48
|
|
79
|
+
ws.column_dimensions["C"].width = 22
|
|
80
|
+
ws.column_dimensions["D"].width = 16
|
|
81
|
+
ws.freeze_panes = "A2"
|
|
82
|
+
|
|
83
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
wb.save(str(output_path))
|