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 +11 -0
- otacon/__main__.py +3 -0
- otacon/_asyncutils.py +35 -0
- otacon/_scanner.py +101 -0
- otacon/_validate.py +146 -0
- otacon/cli.py +492 -0
- otacon/html_report.py +373 -0
- otacon/interactive.py +521 -0
- otacon/models.py +111 -0
- otacon/permutations.py +556 -0
- otacon/py.typed +0 -0
- otacon/reporters.py +480 -0
- otacon/resolver.py +484 -0
- otacon/scoring.py +299 -0
- otacon/theme.py +92 -0
- otacon/whois.py +68 -0
- otacon-1.0.0.dist-info/METADATA +667 -0
- otacon-1.0.0.dist-info/RECORD +22 -0
- otacon-1.0.0.dist-info/WHEEL +5 -0
- otacon-1.0.0.dist-info/entry_points.txt +2 -0
- otacon-1.0.0.dist-info/licenses/LICENSE +21 -0
- otacon-1.0.0.dist-info/top_level.txt +1 -0
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
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)
|