otacon 1.0.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.
otacon/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Otacon — domain impersonation detector.
2
+
3
+ A toolkit for detecting typosquatting, homoglyph attacks and combosquatting.
4
+ """
5
+
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ try:
9
+ __version__ = version("otacon")
10
+ except PackageNotFoundError:
11
+ __version__ = "dev"
otacon/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from otacon.cli import app
2
+
3
+ app()
otacon/_asyncutils.py ADDED
@@ -0,0 +1,35 @@
1
+ """Async utilities — event loop compatibility and concurrent execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from collections.abc import Coroutine
8
+ from typing import Any, TypeVar
9
+
10
+ __all__ = ["run_async"]
11
+
12
+ T = TypeVar("T")
13
+
14
+ # Windows ProactorEventLoop raises ConnectionResetError (WinError 10054) on
15
+ # normal HTTP connection teardowns. SelectorEventLoop doesn't have this issue.
16
+ # Python 3.12+ exposes loop_factory on asyncio.run(); older versions need the
17
+ # now-deprecated set_event_loop_policy path.
18
+ _WIN_LOOP_FACTORY: type[asyncio.AbstractEventLoop] | None = None
19
+ if sys.platform == "win32":
20
+ if sys.version_info >= (3, 12):
21
+ _WIN_LOOP_FACTORY = asyncio.SelectorEventLoop
22
+ else:
23
+ asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
24
+
25
+
26
+ def run_async(coro: Coroutine[Any, Any, T]) -> T:
27
+ """Run an async coroutine with platform-appropriate event loop configuration.
28
+
29
+ On Windows, uses SelectorEventLoop to avoid ConnectionResetError on HTTP teardowns.
30
+ On Python 3.12+, uses loop_factory parameter; on older versions relies on
31
+ set_event_loop_policy configuration.
32
+ """
33
+ if _WIN_LOOP_FACTORY is not None:
34
+ return asyncio.run(coro, loop_factory=_WIN_LOOP_FACTORY) # type: ignore[call-arg]
35
+ return asyncio.run(coro)
otacon/_scanner.py ADDED
@@ -0,0 +1,101 @@
1
+ """The shared scan loop — the single place that drives the resolver over a variant set.
2
+
3
+ Every entry point needs the same pipeline: generate permutations, check them
4
+ concurrently, score each result and collect the registered ones into a report.
5
+ The only thing that actually differs between callers is whether a live progress
6
+ view is drawn, so that is a flag here rather than a reason to keep separate
7
+ copies of the loop.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ from contextlib import AsyncExitStack
14
+
15
+ from rich.console import Console, Group
16
+ from rich.live import Live
17
+ from rich.progress import (
18
+ BarColumn,
19
+ Progress,
20
+ SpinnerColumn,
21
+ TaskID,
22
+ TextColumn,
23
+ TimeElapsedColumn,
24
+ )
25
+
26
+ from . import permutations, reporters, scoring
27
+ from .models import DomainResult, ScanReport
28
+ from .resolver import Resolver
29
+
30
+ _HIJACK_WARNING = (
31
+ "[warn]⚠ Your DNS resolver answers nonexistent domains (NXDOMAIN hijacking)."
32
+ " Hijacked answers were discarded; consider scanning with a clean resolver"
33
+ " (e.g. 1.1.1.1).[/warn]"
34
+ )
35
+
36
+
37
+ def _build_progress(console: Console) -> Progress:
38
+ """Builds the scan progress bar: spinner, label, bar, counter, elapsed time."""
39
+ return Progress(
40
+ SpinnerColumn(style="brand"),
41
+ TextColumn("[field]{task.description}"),
42
+ BarColumn(complete_style="brand", finished_style="ok"),
43
+ TextColumn("[muted]{task.completed}/{task.total}"),
44
+ TimeElapsedColumn(),
45
+ console=console,
46
+ )
47
+
48
+
49
+ async def run_scan(
50
+ target: str,
51
+ concurrency: int,
52
+ check_http: bool,
53
+ console: Console,
54
+ exclude: set[str] | None = None,
55
+ weights: scoring.ScoringWeights | None = None,
56
+ show_progress: bool = True,
57
+ ) -> ScanReport:
58
+ """Scans every permutation of *target* and returns the scored report.
59
+
60
+ With *show_progress* the run streams registered hits into a live table above
61
+ a progress bar; without it the identical loop runs silently — which is what
62
+ ``--quiet`` needs, since there stdout is reserved for the JSON report.
63
+ """
64
+ perms = permutations.generate(target, exclude=exclude)
65
+ report = ScanReport(target=target, total_permutations=len(perms))
66
+ if not perms:
67
+ return report
68
+
69
+ hits: list[DomainResult] = []
70
+ progress = _build_progress(console) if show_progress else None
71
+
72
+ async with AsyncExitStack() as stack:
73
+ live: Live | None = None
74
+ task_id: TaskID | None = None
75
+ if progress is not None:
76
+ task_id = progress.add_task("Checking variants", total=len(perms))
77
+ live = stack.enter_context(
78
+ Live(progress, console=console, refresh_per_second=4, transient=True)
79
+ )
80
+ # Entered after Live so the spinner already covers the resolver's own
81
+ # start-up work (it fetches the target's page title on entry).
82
+ resolver = await stack.enter_async_context(
83
+ Resolver(concurrency=concurrency, check_http=check_http, target=target)
84
+ )
85
+
86
+ for coro in asyncio.as_completed([resolver.check_one(p) for p in perms]):
87
+ result = await coro
88
+ scored = scoring.score(result, target, weights, resolver.target_title)
89
+ report.results.append(scored)
90
+ if progress is not None and task_id is not None:
91
+ progress.advance(task_id)
92
+ if scored.is_registered:
93
+ hits.append(scored)
94
+ if live is not None and progress is not None:
95
+ live.update(Group(progress, reporters.build_live_table(hits, target)))
96
+
97
+ report.dns_hijack_detected = resolver.dns_hijack_detected
98
+
99
+ if report.dns_hijack_detected:
100
+ console.print(_HIJACK_WARNING)
101
+ return report
otacon/_validate.py ADDED
@@ -0,0 +1,146 @@
1
+ """Shared input validators — keep untrusted strings from reaching the network or filesystem.
2
+
3
+ Centralised here so the CLI, interactive mode, and the resolver all enforce the
4
+ same rules instead of each module rolling its own.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import re
11
+ from urllib.parse import urlparse
12
+
13
+ # RFC 1035-ish: labels are 1..63 chars of [a-z0-9-], must not start/end with '-'.
14
+ # We allow uppercase too (normalised later) and ACE/punycode labels (xn--...).
15
+ # Total length cap at 253 octets per RFC. Reject anything with whitespace, NULs,
16
+ # or directory separators outright — none belong in a domain.
17
+ _LABEL_RE = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$")
18
+
19
+ # Windows reserved device names — case-insensitive, with or without extension.
20
+ _WIN_RESERVED = {
21
+ "CON",
22
+ "PRN",
23
+ "AUX",
24
+ "NUL",
25
+ *(f"COM{i}" for i in range(1, 10)),
26
+ *(f"LPT{i}" for i in range(1, 10)),
27
+ }
28
+
29
+
30
+ def normalize_domain(value: str) -> str:
31
+ """Canonicalises a domain string for validation and comparison.
32
+
33
+ Trims surrounding whitespace, lowercases, drops a leading ``www.``, then
34
+ strips a trailing root dot. Centralised so every entry point (CLI,
35
+ interactive mode, permutation exclusions) normalises identically instead of
36
+ re-deriving the chain per call site.
37
+
38
+ Whitelist entries go through the same ``www.`` stripping as targets: they are
39
+ only ever compared against permutation output, and no technique emits a
40
+ ``www.``-prefixed FQDN (``_www_merge`` produces ``wwwexample``), so keeping
41
+ the prefix would make ``--exclude www.example.net`` match nothing at all.
42
+ """
43
+ return value.strip().lower().removeprefix("www.").rstrip(".")
44
+
45
+
46
+ def is_valid_domain(domain: str) -> bool:
47
+ """True when *domain* is a syntactically plausible FQDN.
48
+
49
+ Rejects: empty, >253 chars, control chars / whitespace, bare IPs,
50
+ schemes, paths, or anything not RFC 1035-ish per-label.
51
+ """
52
+ if not domain or len(domain) > 253:
53
+ return False
54
+ if any(c.isspace() or ord(c) < 0x20 for c in domain):
55
+ return False
56
+ if "/" in domain or "\\" in domain or "@" in domain or ":" in domain:
57
+ return False
58
+ # Bare IPs are not impersonation targets.
59
+ try:
60
+ ipaddress.ip_address(domain)
61
+ return False
62
+ except ValueError:
63
+ pass
64
+ labels = domain.rstrip(".").split(".")
65
+ if len(labels) < 2:
66
+ return False
67
+ return all(_LABEL_RE.match(label) for label in labels)
68
+
69
+
70
+ def first_safe_ip(ips: list[str]) -> str | None:
71
+ """Returns the first address in *ips* that is safe to connect to, or None.
72
+
73
+ "Safe" excludes private/loopback/link-local (which covers cloud metadata
74
+ endpoints)/multicast/reserved/unspecified ranges. Any unsafe address in
75
+ *ips* poisons the whole batch — a host answering with a mix of public and
76
+ internal addresses is itself a DNS-rebinding-style red flag, not just a
77
+ case of "pick the good one and ignore the rest".
78
+
79
+ Used by the resolver's HTTP/TLS probing, which connects to
80
+ attacker-influenced hosts and must not let a hostile DNS answer route
81
+ the connection at an internal service.
82
+ """
83
+ safe_ip: str | None = None
84
+ for ip_str in ips:
85
+ try:
86
+ ip = ipaddress.ip_address(ip_str)
87
+ except ValueError:
88
+ continue
89
+ if (
90
+ ip.is_private
91
+ or ip.is_loopback
92
+ or ip.is_link_local
93
+ or ip.is_multicast
94
+ or ip.is_reserved
95
+ or ip.is_unspecified
96
+ ):
97
+ return None
98
+ if safe_ip is None:
99
+ safe_ip = ip_str
100
+ return safe_ip
101
+
102
+
103
+ def parse_redirect(url: str) -> tuple[str, str] | None:
104
+ """Splits a ``Location`` header into ``(hostname, scheme)``, or None if unparseable.
105
+
106
+ *hostname* comes back lowercased with the root dot stripped, and is ``""``
107
+ for a relative Location (``/login``); *scheme* is ``""`` when absent.
108
+
109
+ A Location header is verbatim attacker-controlled output from a lookalike
110
+ host, and ``urlparse`` raises ValueError on a malformed IPv6 authority
111
+ (``http://[evil``) rather than degrading. Every consumer goes through here
112
+ so one hostile redirect cannot take down a scan, a score, or a report.
113
+ """
114
+ try:
115
+ parsed = urlparse(url)
116
+ except ValueError:
117
+ return None
118
+ return (parsed.hostname or "").lower().rstrip("."), parsed.scheme
119
+
120
+
121
+ def safe_relative_path(filename: str, base: str | None = None) -> str | None:
122
+ """Returns a resolved path string when *filename* is safely inside *base* (CWD by default).
123
+
124
+ Refuses: absolute paths, parent-dir escapes, Windows reserved device names,
125
+ NUL bytes, and anything that resolves outside *base*. Returns None on rejection.
126
+ """
127
+ from pathlib import Path
128
+
129
+ if not filename or "\x00" in filename:
130
+ return None
131
+ # Reject Windows-style drive letters cross-platform (Path.drive is empty on POSIX).
132
+ if re.match(r"^[A-Za-z]:", filename):
133
+ return None
134
+ candidate = Path(filename)
135
+ if candidate.is_absolute() or candidate.drive:
136
+ return None
137
+ stem = candidate.name.split(".")[0].upper()
138
+ if stem in _WIN_RESERVED:
139
+ return None
140
+ base_path = Path(base).resolve() if base else Path.cwd().resolve()
141
+ resolved = (base_path / candidate).resolve()
142
+ try:
143
+ resolved.relative_to(base_path)
144
+ except ValueError:
145
+ return None
146
+ return str(resolved)