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