stackscan 2.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.
- stackscan/__init__.py +5 -0
- stackscan/__main__.py +4 -0
- stackscan/analyzers/__init__.py +29 -0
- stackscan/analyzers/creds.py +218 -0
- stackscan/analyzers/cve.py +458 -0
- stackscan/analyzers/exposure.py +73 -0
- stackscan/analyzers/infra.py +114 -0
- stackscan/analyzers/security.py +26 -0
- stackscan/analyzers/services.py +285 -0
- stackscan/analyzers/tech.py +178 -0
- stackscan/cli.py +750 -0
- stackscan/config/__init__.py +19 -0
- stackscan/config/sigdb_loader.py +74 -0
- stackscan/config/sources.py +238 -0
- stackscan/core/__init__.py +3 -0
- stackscan/core/core.py +60 -0
- stackscan/data/builtin.sigdb +0 -0
- stackscan/data/cve.json.gz +0 -0
- stackscan/data/reekeer-logo.png +0 -0
- stackscan/data/subdomains.txt +522 -0
- stackscan/export.py +574 -0
- stackscan/net/__init__.py +22 -0
- stackscan/net/dns.py +168 -0
- stackscan/net/fingerprint.py +41 -0
- stackscan/net/geo.py +70 -0
- stackscan/net/ipinfo.py +94 -0
- stackscan/net/ports.py +219 -0
- stackscan/net/resolver.py +54 -0
- stackscan/net/subdomains.py +457 -0
- stackscan/net/tld.py +126 -0
- stackscan/net/tls.py +78 -0
- stackscan/render.py +541 -0
- stackscan/scan.py +545 -0
- stackscan/theme.py +23 -0
- stackscan/types/__init__.py +43 -0
- stackscan/types/models.py +18 -0
- stackscan/types/output.py +367 -0
- stackscan/utils/__init__.py +4 -0
- stackscan/utils/paths.py +10 -0
- stackscan/utils/urls.py +39 -0
- stackscan-2.1.0.dist-info/METADATA +341 -0
- stackscan-2.1.0.dist-info/RECORD +45 -0
- stackscan-2.1.0.dist-info/WHEEL +4 -0
- stackscan-2.1.0.dist-info/entry_points.txt +2 -0
- stackscan-2.1.0.dist-info/licenses/LICENSE +18 -0
stackscan/net/dns.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ipaddress
|
|
4
|
+
import socket
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
_QUERY_TIMEOUT = 2.0
|
|
10
|
+
_QUERY_LIFETIME = 3.0
|
|
11
|
+
_EXTENDED_TYPES = ("CNAME", "MX", "NS", "TXT", "SOA", "CAA")
|
|
12
|
+
_DNS_ATTEMPTS = 3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DnsResult:
|
|
17
|
+
host: str
|
|
18
|
+
ipv4: tuple[str, ...]
|
|
19
|
+
ipv6: tuple[str, ...]
|
|
20
|
+
cname: tuple[str, ...]
|
|
21
|
+
reverse_dns: dict[str, str]
|
|
22
|
+
mx: tuple[str, ...] = ()
|
|
23
|
+
ns: tuple[str, ...] = ()
|
|
24
|
+
txt: tuple[str, ...] = ()
|
|
25
|
+
soa: tuple[str, ...] = ()
|
|
26
|
+
caa: tuple[str, ...] = ()
|
|
27
|
+
resolver_available: bool = False
|
|
28
|
+
extras: dict[str, tuple[str, ...]] = field(default_factory=dict[str, tuple[str, ...]])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _addrinfo(host: str, family: int) -> list[str]:
|
|
32
|
+
for attempt in range(_DNS_ATTEMPTS):
|
|
33
|
+
try:
|
|
34
|
+
infos = socket.getaddrinfo(host, None, family=family, type=socket.SOCK_STREAM)
|
|
35
|
+
except socket.gaierror as exc:
|
|
36
|
+
if attempt + 1 < _DNS_ATTEMPTS and exc.errno in (socket.EAI_AGAIN, socket.EAI_FAIL):
|
|
37
|
+
continue
|
|
38
|
+
return []
|
|
39
|
+
except OSError:
|
|
40
|
+
return []
|
|
41
|
+
seen: list[str] = []
|
|
42
|
+
for info in infos:
|
|
43
|
+
addr = str(info[4][0])
|
|
44
|
+
if family == socket.AF_INET6 and _is_v4_mapped(addr):
|
|
45
|
+
continue
|
|
46
|
+
if addr not in seen:
|
|
47
|
+
seen.append(addr)
|
|
48
|
+
return seen
|
|
49
|
+
return []
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_v4_mapped(addr: str) -> bool:
|
|
53
|
+
try:
|
|
54
|
+
parsed = ipaddress.ip_address(addr.split("%", 1)[0])
|
|
55
|
+
except ValueError:
|
|
56
|
+
return False
|
|
57
|
+
return isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped is not None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _reverse(ip: str) -> str | None:
|
|
61
|
+
try:
|
|
62
|
+
name, _, _ = socket.gethostbyaddr(ip)
|
|
63
|
+
except OSError:
|
|
64
|
+
return None
|
|
65
|
+
return name
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load_resolver() -> Any | None:
|
|
69
|
+
try:
|
|
70
|
+
from dns import resolver as _dns_resolver
|
|
71
|
+
except ImportError:
|
|
72
|
+
return None
|
|
73
|
+
return cast("Any", _dns_resolver)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _query(resolver: Any, host: str, rdtype: str) -> list[str]:
|
|
77
|
+
from dns import exception as _dns_exc # type: ignore[import-untyped]
|
|
78
|
+
|
|
79
|
+
answers: Any = None
|
|
80
|
+
for attempt in range(_DNS_ATTEMPTS):
|
|
81
|
+
try:
|
|
82
|
+
answers = resolver.resolve(host, rdtype, raise_on_no_answer=False)
|
|
83
|
+
break
|
|
84
|
+
except (_dns_exc.Timeout, _dns_exc.DNSException) as exc:
|
|
85
|
+
if attempt + 1 < _DNS_ATTEMPTS and isinstance(exc, _dns_exc.Timeout):
|
|
86
|
+
continue
|
|
87
|
+
return []
|
|
88
|
+
except Exception:
|
|
89
|
+
return []
|
|
90
|
+
if answers is None:
|
|
91
|
+
return []
|
|
92
|
+
values: list[str] = []
|
|
93
|
+
for rdata in cast("list[Any]", answers):
|
|
94
|
+
text = _format_rdata(rdtype, rdata)
|
|
95
|
+
if text and text not in values:
|
|
96
|
+
values.append(text)
|
|
97
|
+
return values
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _format_rdata(rdtype: str, rdata: Any) -> str:
|
|
101
|
+
if rdtype == "MX":
|
|
102
|
+
pref = getattr(rdata, "preference", "")
|
|
103
|
+
exchange = str(getattr(rdata, "exchange", "")).rstrip(".") or "."
|
|
104
|
+
return f"{pref} {exchange}".strip()
|
|
105
|
+
if rdtype in {"NS", "CNAME"}:
|
|
106
|
+
return str(getattr(rdata, "target", rdata)).rstrip(".")
|
|
107
|
+
if rdtype == "TXT":
|
|
108
|
+
strings = getattr(rdata, "strings", None)
|
|
109
|
+
if strings:
|
|
110
|
+
return "".join(
|
|
111
|
+
part.decode("utf-8", "replace") if isinstance(part, bytes) else str(part)
|
|
112
|
+
for part in cast("list[Any]", strings)
|
|
113
|
+
)
|
|
114
|
+
return str(rdata).strip('"')
|
|
115
|
+
if rdtype == "SOA":
|
|
116
|
+
mname = str(getattr(rdata, "mname", "")).rstrip(".")
|
|
117
|
+
rname = str(getattr(rdata, "rname", "")).rstrip(".")
|
|
118
|
+
serial = getattr(rdata, "serial", "")
|
|
119
|
+
return f"{mname} {rname} {serial}".strip()
|
|
120
|
+
if rdtype == "CAA":
|
|
121
|
+
flags = getattr(rdata, "flags", "")
|
|
122
|
+
tag = getattr(rdata, "tag", "")
|
|
123
|
+
value = getattr(rdata, "value", "")
|
|
124
|
+
if isinstance(tag, bytes):
|
|
125
|
+
tag = tag.decode("utf-8", "replace")
|
|
126
|
+
if isinstance(value, bytes):
|
|
127
|
+
value = value.decode("utf-8", "replace")
|
|
128
|
+
return f"{flags} {tag} {value}".strip()
|
|
129
|
+
return str(rdata).rstrip(".")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _extended_records(host: str) -> dict[str, tuple[str, ...]]:
|
|
133
|
+
resolver_mod = _load_resolver()
|
|
134
|
+
if resolver_mod is None:
|
|
135
|
+
return {}
|
|
136
|
+
resolver = resolver_mod.Resolver()
|
|
137
|
+
resolver.timeout = _QUERY_TIMEOUT
|
|
138
|
+
resolver.lifetime = _QUERY_LIFETIME
|
|
139
|
+
with ThreadPoolExecutor(max_workers=len(_EXTENDED_TYPES)) as pool:
|
|
140
|
+
futures = {
|
|
141
|
+
rdtype: pool.submit(_query, resolver, host, rdtype) for rdtype in _EXTENDED_TYPES
|
|
142
|
+
}
|
|
143
|
+
return {rdtype: tuple(future.result()) for rdtype, future in futures.items()}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def resolve_host(host: str, *, reverse: bool = True) -> DnsResult:
|
|
147
|
+
ipv4 = tuple(_addrinfo(host, socket.AF_INET))
|
|
148
|
+
ipv6 = tuple(_addrinfo(host, socket.AF_INET6))
|
|
149
|
+
records = _extended_records(host)
|
|
150
|
+
reverse_map: dict[str, str] = {}
|
|
151
|
+
if reverse:
|
|
152
|
+
for ip in (*ipv4, *ipv6):
|
|
153
|
+
name = _reverse(ip)
|
|
154
|
+
if name:
|
|
155
|
+
reverse_map[ip] = name
|
|
156
|
+
return DnsResult(
|
|
157
|
+
host=host,
|
|
158
|
+
ipv4=ipv4,
|
|
159
|
+
ipv6=ipv6,
|
|
160
|
+
cname=records.get("CNAME", ()),
|
|
161
|
+
reverse_dns=reverse_map,
|
|
162
|
+
mx=records.get("MX", ()),
|
|
163
|
+
ns=records.get("NS", ()),
|
|
164
|
+
txt=records.get("TXT", ()),
|
|
165
|
+
soa=records.get("SOA", ()),
|
|
166
|
+
caa=records.get("CAA", ()),
|
|
167
|
+
resolver_available=bool(records),
|
|
168
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
_BANNER_SIGNATURES: tuple[tuple[re.Pattern[str], str, str], ...] = (
|
|
6
|
+
(re.compile("SSH-\\d+\\.\\d+-OpenSSH[_-]([\\w.]+)", re.I), "ssh", "OpenSSH"),
|
|
7
|
+
(re.compile("SSH-\\d+\\.\\d+-Dropbear[_-]?([\\w.]+)?", re.I), "ssh", "Dropbear"),
|
|
8
|
+
(re.compile("220[- ].*ProFTPD (\\d[\\w.]+)", re.I), "ftp", "ProFTPD"),
|
|
9
|
+
(re.compile("220[- ].*\\(?vsFTPd (\\d[\\w.]+)\\)?", re.I), "ftp", "vsftpd"),
|
|
10
|
+
(re.compile("220[- ].*Pure-FTPd", re.I), "ftp", "Pure-FTPd"),
|
|
11
|
+
(re.compile("220[- ].*FileZilla Server (\\d[\\w.]+)", re.I), "ftp", "FileZilla"),
|
|
12
|
+
(re.compile("220[- ].*Exim (\\d[\\w.]+)", re.I), "smtp", "Exim"),
|
|
13
|
+
(re.compile("220[- ].*Postfix", re.I), "smtp", "Postfix"),
|
|
14
|
+
(re.compile("220[- ].*Sendmail (\\d[\\w.]+)", re.I), "smtp", "Sendmail"),
|
|
15
|
+
(re.compile("\\+OK.*Dovecot", re.I), "pop3", "Dovecot"),
|
|
16
|
+
(re.compile("\\* OK.*Dovecot", re.I), "imap", "Dovecot"),
|
|
17
|
+
(re.compile("^-ERR|^\\+PONG|^\\$\\d", re.I), "redis", "Redis"),
|
|
18
|
+
(re.compile("RFB (\\d+\\.\\d+)", re.I), "vnc", "VNC"),
|
|
19
|
+
)
|
|
20
|
+
_HTTP_SERVER_RE = re.compile("^server:\\s*(.+?)\\s*$", re.I | re.M)
|
|
21
|
+
_SERVER_TOKEN_RE = re.compile("([A-Za-z][A-Za-z0-9._+-]*?)/([\\w.]+)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fingerprint_banner(banner: str) -> tuple[str | None, str | None, str | None]:
|
|
25
|
+
for pattern, service, product in _BANNER_SIGNATURES:
|
|
26
|
+
match = pattern.search(banner)
|
|
27
|
+
if match:
|
|
28
|
+
version = match.group(1) if match.groups() else None
|
|
29
|
+
return (service, product, version)
|
|
30
|
+
return (None, None, None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fingerprint_http(raw_response: str) -> tuple[str | None, str | None]:
|
|
34
|
+
match = _HTTP_SERVER_RE.search(raw_response)
|
|
35
|
+
if not match:
|
|
36
|
+
return (None, None)
|
|
37
|
+
server = match.group(1).strip()
|
|
38
|
+
token = _SERVER_TOKEN_RE.search(server)
|
|
39
|
+
if token:
|
|
40
|
+
return (token.group(1), token.group(2))
|
|
41
|
+
return (server, None)
|
stackscan/net/geo.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class GeoProvider:
|
|
9
|
+
def __init__(self, db_path: str | Path | None = None) -> None:
|
|
10
|
+
env_path = os.environ.get("STACKSCAN_GEOIP_DB")
|
|
11
|
+
resolved = db_path or env_path
|
|
12
|
+
self._db_path = Path(resolved) if resolved else None
|
|
13
|
+
self._reader: Any = None
|
|
14
|
+
self._unavailable = False
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def enabled(self) -> bool:
|
|
18
|
+
return self._db_path is not None and self._db_path.is_file()
|
|
19
|
+
|
|
20
|
+
def _ensure_reader(self) -> Any:
|
|
21
|
+
if self._reader is not None or self._unavailable:
|
|
22
|
+
return self._reader
|
|
23
|
+
if not self.enabled:
|
|
24
|
+
self._unavailable = True
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
from geoip2 import database as _geoip_db
|
|
28
|
+
except ImportError:
|
|
29
|
+
self._unavailable = True
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
self._reader = cast("Any", _geoip_db).Reader(str(self._db_path))
|
|
33
|
+
except Exception:
|
|
34
|
+
self._unavailable = True
|
|
35
|
+
return None
|
|
36
|
+
return self._reader
|
|
37
|
+
|
|
38
|
+
def lookup(self, ip: str) -> dict[str, str]:
|
|
39
|
+
reader: Any = self._ensure_reader()
|
|
40
|
+
if reader is None:
|
|
41
|
+
return {}
|
|
42
|
+
try:
|
|
43
|
+
response: Any = reader.city(ip)
|
|
44
|
+
except Exception:
|
|
45
|
+
return {}
|
|
46
|
+
out: dict[str, str] = {}
|
|
47
|
+
country = getattr(getattr(response, "country", None), "iso_code", None)
|
|
48
|
+
country_name = getattr(getattr(response, "country", None), "name", None)
|
|
49
|
+
city = getattr(getattr(response, "city", None), "name", None)
|
|
50
|
+
if isinstance(country, str):
|
|
51
|
+
out["country_code"] = country
|
|
52
|
+
if isinstance(country_name, str):
|
|
53
|
+
out["country"] = country_name
|
|
54
|
+
if isinstance(city, str):
|
|
55
|
+
out["city"] = city
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def lookup_geo(
|
|
60
|
+
ips: tuple[str, ...], provider: GeoProvider | None = None
|
|
61
|
+
) -> dict[str, dict[str, str]]:
|
|
62
|
+
provider = provider or GeoProvider()
|
|
63
|
+
if not provider.enabled:
|
|
64
|
+
return {}
|
|
65
|
+
result: dict[str, dict[str, str]] = {}
|
|
66
|
+
for ip in ips:
|
|
67
|
+
data = provider.lookup(ip)
|
|
68
|
+
if data:
|
|
69
|
+
result[ip] = data
|
|
70
|
+
return result
|
stackscan/net/ipinfo.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import ipaddress
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from stackscan.types import IpInfo
|
|
8
|
+
|
|
9
|
+
_IPWHO_URL = "https://ipwho.is/"
|
|
10
|
+
_CDN_KEYWORDS = (
|
|
11
|
+
"cloudflare",
|
|
12
|
+
"fastly",
|
|
13
|
+
"akamai",
|
|
14
|
+
"cloudfront",
|
|
15
|
+
"amazon",
|
|
16
|
+
"aws",
|
|
17
|
+
"google",
|
|
18
|
+
"microsoft",
|
|
19
|
+
"azure",
|
|
20
|
+
"incapsula",
|
|
21
|
+
"imperva",
|
|
22
|
+
"sucuri",
|
|
23
|
+
"stackpath",
|
|
24
|
+
"cdn77",
|
|
25
|
+
"bunny",
|
|
26
|
+
"keycdn",
|
|
27
|
+
"limelight",
|
|
28
|
+
"edgecast",
|
|
29
|
+
"gcore",
|
|
30
|
+
"edgio",
|
|
31
|
+
"verizon",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_public(ip: str) -> bool:
|
|
36
|
+
try:
|
|
37
|
+
parsed = ipaddress.ip_address(ip.split("%", 1)[0])
|
|
38
|
+
except ValueError:
|
|
39
|
+
return False
|
|
40
|
+
return not (parsed.is_private or parsed.is_loopback or parsed.is_link_local)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _looks_like_cdn(*values: str | None) -> bool:
|
|
44
|
+
blob = " ".join(v.lower() for v in values if v)
|
|
45
|
+
return any(keyword in blob for keyword in _CDN_KEYWORDS)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def enrich_ips(
|
|
49
|
+
ips: tuple[str, ...],
|
|
50
|
+
*,
|
|
51
|
+
timeout: float = 10.0,
|
|
52
|
+
workers: int = 5,
|
|
53
|
+
sources: dict[str, str] | None = None,
|
|
54
|
+
) -> list[IpInfo]:
|
|
55
|
+
import aiohttp
|
|
56
|
+
|
|
57
|
+
targets = [ip for ip in dict.fromkeys(ips) if _is_public(ip)]
|
|
58
|
+
if not targets:
|
|
59
|
+
return []
|
|
60
|
+
sources = sources or {}
|
|
61
|
+
semaphore = asyncio.Semaphore(max(workers, 1))
|
|
62
|
+
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
63
|
+
async with aiohttp.ClientSession(timeout=client_timeout) as session:
|
|
64
|
+
|
|
65
|
+
async def lookup(ip: str) -> IpInfo | None:
|
|
66
|
+
async with semaphore:
|
|
67
|
+
try:
|
|
68
|
+
async with session.get(
|
|
69
|
+
f"{_IPWHO_URL}{ip}", headers={"User-Agent": "stackscan"}
|
|
70
|
+
) as resp:
|
|
71
|
+
if resp.status != 200:
|
|
72
|
+
return None
|
|
73
|
+
data = cast("dict[str, Any]", await resp.json())
|
|
74
|
+
except (aiohttp.ClientError, TimeoutError, ValueError, OSError):
|
|
75
|
+
return None
|
|
76
|
+
if not data.get("success"):
|
|
77
|
+
return None
|
|
78
|
+
connection = cast("dict[str, Any]", data.get("connection") or {})
|
|
79
|
+
asn = connection.get("asn")
|
|
80
|
+
org = connection.get("org")
|
|
81
|
+
isp = connection.get("isp")
|
|
82
|
+
return IpInfo(
|
|
83
|
+
ip=ip,
|
|
84
|
+
country=data.get("country"),
|
|
85
|
+
city=data.get("city"),
|
|
86
|
+
org=org,
|
|
87
|
+
isp=isp,
|
|
88
|
+
asn=f"AS{asn}" if asn else None,
|
|
89
|
+
is_cdn=_looks_like_cdn(org, isp),
|
|
90
|
+
source=sources.get(ip, ""),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
results = await asyncio.gather(*(lookup(ip) for ip in targets))
|
|
94
|
+
return [info for info in results if info is not None]
|
stackscan/net/ports.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import shutil
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from stackscan.net.fingerprint import fingerprint_banner, fingerprint_http
|
|
8
|
+
from stackscan.types import Port, PortScan
|
|
9
|
+
|
|
10
|
+
COMMON_PORTS: dict[int, tuple[str, str]] = {
|
|
11
|
+
21: ("ftp", "banner"),
|
|
12
|
+
22: ("ssh", "banner"),
|
|
13
|
+
23: ("telnet", "banner"),
|
|
14
|
+
25: ("smtp", "banner"),
|
|
15
|
+
53: ("domain", "none"),
|
|
16
|
+
80: ("http", "http"),
|
|
17
|
+
110: ("pop3", "banner"),
|
|
18
|
+
111: ("rpcbind", "none"),
|
|
19
|
+
135: ("msrpc", "none"),
|
|
20
|
+
139: ("netbios-ssn", "none"),
|
|
21
|
+
143: ("imap", "banner"),
|
|
22
|
+
443: ("https", "http"),
|
|
23
|
+
445: ("microsoft-ds", "none"),
|
|
24
|
+
465: ("smtps", "banner"),
|
|
25
|
+
554: ("rtsp", "rtsp"),
|
|
26
|
+
587: ("submission", "banner"),
|
|
27
|
+
631: ("ipp", "http"),
|
|
28
|
+
993: ("imaps", "none"),
|
|
29
|
+
995: ("pop3s", "none"),
|
|
30
|
+
1433: ("ms-sql", "none"),
|
|
31
|
+
1723: ("pptp", "none"),
|
|
32
|
+
1883: ("mqtt", "banner"),
|
|
33
|
+
2082: ("cpanel", "http"),
|
|
34
|
+
2083: ("cpanel-ssl", "http"),
|
|
35
|
+
2222: ("ssh-alt", "banner"),
|
|
36
|
+
3000: ("http-dev", "http"),
|
|
37
|
+
3306: ("mysql", "banner"),
|
|
38
|
+
3389: ("ms-wbt-server", "none"),
|
|
39
|
+
5432: ("postgresql", "none"),
|
|
40
|
+
5900: ("vnc", "banner"),
|
|
41
|
+
5985: ("wsman", "none"),
|
|
42
|
+
6379: ("redis", "banner"),
|
|
43
|
+
7547: ("cwmp", "http"),
|
|
44
|
+
8000: ("http-alt", "http"),
|
|
45
|
+
8080: ("http-proxy", "http"),
|
|
46
|
+
8081: ("http-alt", "http"),
|
|
47
|
+
8443: ("https-alt", "http"),
|
|
48
|
+
8554: ("rtsp-alt", "rtsp"),
|
|
49
|
+
8888: ("http-alt", "http"),
|
|
50
|
+
9000: ("http-alt", "http"),
|
|
51
|
+
9200: ("elasticsearch", "http"),
|
|
52
|
+
11211: ("memcached", "none"),
|
|
53
|
+
27017: ("mongodb", "none"),
|
|
54
|
+
}
|
|
55
|
+
_BANNER_BYTES = 512
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def nmap_available() -> bool:
|
|
59
|
+
return shutil.which("nmap") is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def default_ports() -> tuple[int, ...]:
|
|
63
|
+
return tuple(sorted(COMMON_PORTS))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def scan_ports(
|
|
67
|
+
host: str,
|
|
68
|
+
*,
|
|
69
|
+
ports: tuple[int, ...] | None = None,
|
|
70
|
+
timeout: float = 2.0,
|
|
71
|
+
prefer_nmap: bool = True,
|
|
72
|
+
workers: int = 100,
|
|
73
|
+
) -> PortScan:
|
|
74
|
+
targets = ports or default_ports()
|
|
75
|
+
if prefer_nmap and nmap_available():
|
|
76
|
+
result = await asyncio.to_thread(_run_nmap, host, targets)
|
|
77
|
+
if result is not None:
|
|
78
|
+
return result
|
|
79
|
+
return await _connect_scan(host, targets, timeout, workers)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _run_nmap(host: str, ports: tuple[int, ...]) -> PortScan | None:
|
|
83
|
+
try:
|
|
84
|
+
import nmap
|
|
85
|
+
except ImportError:
|
|
86
|
+
return None
|
|
87
|
+
try:
|
|
88
|
+
scanner = cast("Any", nmap).PortScanner()
|
|
89
|
+
port_arg = ",".join(str(p) for p in ports)
|
|
90
|
+
scanner.scan(host, port_arg, arguments="-Pn -sV --version-light -T4")
|
|
91
|
+
except Exception:
|
|
92
|
+
return None
|
|
93
|
+
found: list[Port] = []
|
|
94
|
+
try:
|
|
95
|
+
hosts = cast("list[str]", scanner.all_hosts())
|
|
96
|
+
except Exception:
|
|
97
|
+
return None
|
|
98
|
+
for scanned in hosts:
|
|
99
|
+
tcp = cast("dict[int, dict[str, Any]]", scanner[scanned].get("tcp", {}))
|
|
100
|
+
for number, info in tcp.items():
|
|
101
|
+
if info.get("state") != "open":
|
|
102
|
+
continue
|
|
103
|
+
product = info.get("product") or None
|
|
104
|
+
version = info.get("version") or None
|
|
105
|
+
extra = info.get("extrainfo") or None
|
|
106
|
+
if extra and version:
|
|
107
|
+
version = f"{version} ({extra})"
|
|
108
|
+
found.append(
|
|
109
|
+
Port(
|
|
110
|
+
port=int(number),
|
|
111
|
+
protocol="tcp",
|
|
112
|
+
state="open",
|
|
113
|
+
service=info.get("name") or None,
|
|
114
|
+
product=product,
|
|
115
|
+
version=version,
|
|
116
|
+
host=host,
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
found.sort(key=lambda p: p.port)
|
|
120
|
+
return PortScan(scanner="nmap", ports=tuple(found))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _connect_scan(
|
|
124
|
+
host: str, ports: tuple[int, ...], timeout: float, workers: int
|
|
125
|
+
) -> PortScan:
|
|
126
|
+
semaphore = asyncio.Semaphore(max(workers, 1))
|
|
127
|
+
|
|
128
|
+
async def probe(port: int) -> Port | None:
|
|
129
|
+
async with semaphore:
|
|
130
|
+
return await _probe_port(host, port, timeout)
|
|
131
|
+
|
|
132
|
+
results = await asyncio.gather(*(probe(port) for port in ports))
|
|
133
|
+
open_ports = tuple(sorted((p for p in results if p is not None), key=lambda p: p.port))
|
|
134
|
+
note = "pure-Python connect scan (install nmap + python-nmap for full -sV detection)"
|
|
135
|
+
return PortScan(scanner="connect", ports=open_ports, note=note)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
async def _probe_port(host: str, port: int, timeout: float) -> Port | None:
|
|
139
|
+
service, probe = COMMON_PORTS.get(port, ("unknown", "banner"))
|
|
140
|
+
try:
|
|
141
|
+
reader, writer = await asyncio.wait_for(
|
|
142
|
+
asyncio.open_connection(host, port), timeout=timeout
|
|
143
|
+
)
|
|
144
|
+
except (TimeoutError, OSError):
|
|
145
|
+
return None
|
|
146
|
+
product: str | None = None
|
|
147
|
+
version: str | None = None
|
|
148
|
+
try:
|
|
149
|
+
if probe == "banner":
|
|
150
|
+
banner = await _read(reader, timeout)
|
|
151
|
+
if banner:
|
|
152
|
+
svc, product, version = fingerprint_banner(banner)
|
|
153
|
+
service = svc or service
|
|
154
|
+
if not product:
|
|
155
|
+
version = banner[:80]
|
|
156
|
+
elif probe == "http":
|
|
157
|
+
product, version = await _http_probe(host, port, reader, writer, timeout)
|
|
158
|
+
elif probe == "rtsp":
|
|
159
|
+
product, version = await _rtsp_probe(host, port, reader, writer, timeout)
|
|
160
|
+
finally:
|
|
161
|
+
writer.close()
|
|
162
|
+
try:
|
|
163
|
+
await writer.wait_closed()
|
|
164
|
+
except (TimeoutError, OSError):
|
|
165
|
+
pass
|
|
166
|
+
return Port(
|
|
167
|
+
port=port,
|
|
168
|
+
protocol="tcp",
|
|
169
|
+
state="open",
|
|
170
|
+
service=service,
|
|
171
|
+
product=product,
|
|
172
|
+
version=version,
|
|
173
|
+
host=host,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def _read(reader: asyncio.StreamReader, timeout: float) -> str | None:
|
|
178
|
+
try:
|
|
179
|
+
data = await asyncio.wait_for(reader.read(_BANNER_BYTES), timeout=min(timeout, 2.5))
|
|
180
|
+
except (TimeoutError, OSError):
|
|
181
|
+
return None
|
|
182
|
+
if not data:
|
|
183
|
+
return None
|
|
184
|
+
return data.decode("utf-8", "replace").strip() or None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def _http_probe(
|
|
188
|
+
host: str, port: int, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, timeout: float
|
|
189
|
+
) -> tuple[str | None, str | None]:
|
|
190
|
+
request = (
|
|
191
|
+
f"GET / HTTP/1.0\r\nHost: {host}\r\nUser-Agent: stackscan\r\nConnection: close\r\n\r\n"
|
|
192
|
+
)
|
|
193
|
+
try:
|
|
194
|
+
writer.write(request.encode("ascii", "ignore"))
|
|
195
|
+
await asyncio.wait_for(writer.drain(), timeout=min(timeout, 2.5))
|
|
196
|
+
except (TimeoutError, OSError):
|
|
197
|
+
return (None, None)
|
|
198
|
+
raw = await _read(reader, timeout)
|
|
199
|
+
if not raw:
|
|
200
|
+
return (None, None)
|
|
201
|
+
return fingerprint_http(raw)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def _rtsp_probe(
|
|
205
|
+
host: str, port: int, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, timeout: float
|
|
206
|
+
) -> tuple[str | None, str | None]:
|
|
207
|
+
request = f"OPTIONS rtsp://{host}:{port} RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: stackscan\r\n\r\n"
|
|
208
|
+
try:
|
|
209
|
+
writer.write(request.encode("ascii", "ignore"))
|
|
210
|
+
await asyncio.wait_for(writer.drain(), timeout=min(timeout, 2.5))
|
|
211
|
+
except (TimeoutError, OSError):
|
|
212
|
+
return (None, None)
|
|
213
|
+
raw = await _read(reader, timeout)
|
|
214
|
+
if not raw:
|
|
215
|
+
return (None, None)
|
|
216
|
+
server = fingerprint_http(raw)
|
|
217
|
+
if server != (None, None):
|
|
218
|
+
return server
|
|
219
|
+
return ("RTSP", None)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
import aiohttp
|
|
6
|
+
from aiohttp.abc import AbstractResolver, ResolveResult
|
|
7
|
+
from aiohttp.resolver import ThreadedResolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FallbackResolver(AbstractResolver):
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._system = ThreadedResolver()
|
|
13
|
+
|
|
14
|
+
async def resolve(
|
|
15
|
+
self, host: str, port: int = 0, family: socket.AddressFamily = socket.AddressFamily.AF_INET
|
|
16
|
+
) -> list[ResolveResult]:
|
|
17
|
+
try:
|
|
18
|
+
return await self._system.resolve(host, port, family)
|
|
19
|
+
except OSError:
|
|
20
|
+
results = await self._fallback(host, port, family)
|
|
21
|
+
if results:
|
|
22
|
+
return results
|
|
23
|
+
raise
|
|
24
|
+
|
|
25
|
+
async def _fallback(
|
|
26
|
+
self, host: str, port: int, family: socket.AddressFamily
|
|
27
|
+
) -> list[ResolveResult]:
|
|
28
|
+
if family not in (socket.AddressFamily.AF_INET, socket.AF_UNSPEC):
|
|
29
|
+
return []
|
|
30
|
+
try:
|
|
31
|
+
from stackscan.net.subdomains import resolve_many
|
|
32
|
+
|
|
33
|
+
resolved = await resolve_many([host], 3.0, 100)
|
|
34
|
+
except Exception:
|
|
35
|
+
return []
|
|
36
|
+
addresses = resolved.get(host, ())
|
|
37
|
+
return [
|
|
38
|
+
ResolveResult(
|
|
39
|
+
hostname=host,
|
|
40
|
+
host=address,
|
|
41
|
+
port=port,
|
|
42
|
+
family=socket.AF_INET,
|
|
43
|
+
proto=0,
|
|
44
|
+
flags=socket.AI_NUMERICHOST,
|
|
45
|
+
)
|
|
46
|
+
for address in addresses
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
async def close(self) -> None:
|
|
50
|
+
await self._system.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_connector() -> aiohttp.TCPConnector:
|
|
54
|
+
return aiohttp.TCPConnector(resolver=FallbackResolver())
|