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/__init__.py
ADDED
stackscan/__main__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from stackscan.analyzers.creds import check_default_creds
|
|
2
|
+
from stackscan.analyzers.cve import (
|
|
3
|
+
extract_software,
|
|
4
|
+
match_cves,
|
|
5
|
+
match_cves_online,
|
|
6
|
+
merge_cve_matches,
|
|
7
|
+
software_from_ports,
|
|
8
|
+
)
|
|
9
|
+
from stackscan.analyzers.exposure import ExposureProbe, analyze_exposure
|
|
10
|
+
from stackscan.analyzers.infra import analyze_infra
|
|
11
|
+
from stackscan.analyzers.security import analyze_security_headers
|
|
12
|
+
from stackscan.analyzers.services import classify_services, port_category
|
|
13
|
+
from stackscan.analyzers.tech import TechAnalyzer
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"ExposureProbe",
|
|
17
|
+
"TechAnalyzer",
|
|
18
|
+
"analyze_exposure",
|
|
19
|
+
"analyze_infra",
|
|
20
|
+
"analyze_security_headers",
|
|
21
|
+
"check_default_creds",
|
|
22
|
+
"classify_services",
|
|
23
|
+
"port_category",
|
|
24
|
+
"extract_software",
|
|
25
|
+
"match_cves",
|
|
26
|
+
"match_cves_online",
|
|
27
|
+
"merge_cve_matches",
|
|
28
|
+
"software_from_ports",
|
|
29
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import csv
|
|
5
|
+
import urllib.request
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
from stackscan.types import CredFinding, PortScan
|
|
11
|
+
from stackscan.utils import db_dir
|
|
12
|
+
|
|
13
|
+
_CREDS_URL = "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Default-Credentials/default-passwords.csv"
|
|
14
|
+
_CREDS_TIMEOUT = 20
|
|
15
|
+
_CREDS_MAX_BYTES = 4 * 1024 * 1024
|
|
16
|
+
_DEVICE_KEYWORDS = (
|
|
17
|
+
"camera",
|
|
18
|
+
"ipcam",
|
|
19
|
+
"netcam",
|
|
20
|
+
"webcam",
|
|
21
|
+
"dvr",
|
|
22
|
+
"nvr",
|
|
23
|
+
"cctv",
|
|
24
|
+
"hikvision",
|
|
25
|
+
"dahua",
|
|
26
|
+
"axis",
|
|
27
|
+
"foscam",
|
|
28
|
+
"vivotek",
|
|
29
|
+
"goahead",
|
|
30
|
+
"boa",
|
|
31
|
+
"rompager",
|
|
32
|
+
"router",
|
|
33
|
+
"gateway",
|
|
34
|
+
"modem",
|
|
35
|
+
"nas",
|
|
36
|
+
"synology",
|
|
37
|
+
"qnap",
|
|
38
|
+
"printer",
|
|
39
|
+
"webcamxp",
|
|
40
|
+
"network camera",
|
|
41
|
+
"surveillance",
|
|
42
|
+
"uc-httpd",
|
|
43
|
+
)
|
|
44
|
+
_BUILTIN_CREDS: tuple[tuple[str, str], ...] = (
|
|
45
|
+
("admin", "admin"),
|
|
46
|
+
("admin", ""),
|
|
47
|
+
("admin", "12345"),
|
|
48
|
+
("admin", "123456"),
|
|
49
|
+
("admin", "password"),
|
|
50
|
+
("admin", "1234"),
|
|
51
|
+
("root", "root"),
|
|
52
|
+
("root", "admin"),
|
|
53
|
+
("service", "service"),
|
|
54
|
+
("user", "user"),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _download_creds() -> str:
|
|
59
|
+
request = urllib.request.Request(_CREDS_URL, headers={"User-Agent": "stackscan"})
|
|
60
|
+
with urllib.request.urlopen(request, timeout=_CREDS_TIMEOUT) as response:
|
|
61
|
+
return response.read(_CREDS_MAX_BYTES).decode("utf-8", "replace")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_creds_csv(text: str) -> list[tuple[str, str]]:
|
|
65
|
+
pairs: list[tuple[str, str]] = []
|
|
66
|
+
seen: set[tuple[str, str]] = set()
|
|
67
|
+
reader = csv.reader(text.splitlines())
|
|
68
|
+
for i, row in enumerate(reader):
|
|
69
|
+
if i == 0 or len(row) < 3:
|
|
70
|
+
continue
|
|
71
|
+
user = "" if row[1].strip() in ("<BLANK>", "<blank>") else row[1].strip()
|
|
72
|
+
password = "" if row[2].strip() in ("<BLANK>", "<blank>") else row[2].strip()
|
|
73
|
+
if len(user) > 40 or len(password) > 40 or " " in user:
|
|
74
|
+
continue
|
|
75
|
+
pair = (user, password)
|
|
76
|
+
if pair in seen:
|
|
77
|
+
continue
|
|
78
|
+
seen.add(pair)
|
|
79
|
+
pairs.append(pair)
|
|
80
|
+
return pairs
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@lru_cache(maxsize=1)
|
|
84
|
+
def load_default_creds() -> tuple[tuple[str, str], ...]:
|
|
85
|
+
cache = db_dir() / "seclists-default-creds.csv"
|
|
86
|
+
text: str | None = None
|
|
87
|
+
if cache.is_file():
|
|
88
|
+
try:
|
|
89
|
+
text = cache.read_text("utf-8")
|
|
90
|
+
except OSError:
|
|
91
|
+
text = None
|
|
92
|
+
if text is None:
|
|
93
|
+
try:
|
|
94
|
+
text = _download_creds()
|
|
95
|
+
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
cache.write_text(text, encoding="utf-8")
|
|
97
|
+
except (OSError, ValueError):
|
|
98
|
+
text = None
|
|
99
|
+
combined: list[tuple[str, str]] = []
|
|
100
|
+
seen: set[tuple[str, str]] = set()
|
|
101
|
+
for pair in (*_BUILTIN_CREDS, *(_parse_creds_csv(text) if text else [])):
|
|
102
|
+
if pair not in seen:
|
|
103
|
+
seen.add(pair)
|
|
104
|
+
combined.append(pair)
|
|
105
|
+
return tuple(combined)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _http_ports(scan: PortScan | None) -> list[tuple[int, bool]]:
|
|
109
|
+
if scan is None:
|
|
110
|
+
return []
|
|
111
|
+
out: list[tuple[int, bool]] = []
|
|
112
|
+
for port in scan.ports:
|
|
113
|
+
service = (port.service or "").lower()
|
|
114
|
+
if port.port in (443, 8443, 2083) or "https" in service or "ssl" in service:
|
|
115
|
+
out.append((port.port, True))
|
|
116
|
+
elif "http" in service or port.port in (80, 8080, 8000, 8081, 8888, 9000, 631, 7547):
|
|
117
|
+
out.append((port.port, False))
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _looks_like_device(realm: str, server: str) -> bool:
|
|
122
|
+
blob = f"{realm} {server}".lower()
|
|
123
|
+
return any(keyword in blob for keyword in _DEVICE_KEYWORDS)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def check_default_creds(
|
|
127
|
+
host: str,
|
|
128
|
+
scan: PortScan | None,
|
|
129
|
+
*,
|
|
130
|
+
timeout: float = 6.0,
|
|
131
|
+
insecure: bool = True,
|
|
132
|
+
workers: int = 10,
|
|
133
|
+
cred_limit: int = 100,
|
|
134
|
+
) -> list[CredFinding]:
|
|
135
|
+
ports = _http_ports(scan)
|
|
136
|
+
if not ports:
|
|
137
|
+
return []
|
|
138
|
+
creds = await asyncio.to_thread(load_default_creds)
|
|
139
|
+
if cred_limit > 0:
|
|
140
|
+
creds = creds[:cred_limit]
|
|
141
|
+
connector = aiohttp.TCPConnector(ssl=False, limit=max(workers, 1))
|
|
142
|
+
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
143
|
+
semaphore = asyncio.Semaphore(max(workers, 1))
|
|
144
|
+
findings: list[CredFinding] = []
|
|
145
|
+
async with aiohttp.ClientSession(connector=connector, timeout=client_timeout) as session:
|
|
146
|
+
|
|
147
|
+
async def check(port: int, tls: bool) -> None:
|
|
148
|
+
async with semaphore:
|
|
149
|
+
finding = await _check_endpoint(session, host, port, tls, creds)
|
|
150
|
+
if finding is not None:
|
|
151
|
+
findings.append(finding)
|
|
152
|
+
|
|
153
|
+
await asyncio.gather(*(check(port, tls) for port, tls in ports))
|
|
154
|
+
findings.sort(key=lambda f: (f.kind != "default-creds", f.kind != "open-no-auth", f.target))
|
|
155
|
+
return findings
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _check_endpoint(
|
|
159
|
+
session: aiohttp.ClientSession,
|
|
160
|
+
host: str,
|
|
161
|
+
port: int,
|
|
162
|
+
tls: bool,
|
|
163
|
+
creds: tuple[tuple[str, str], ...],
|
|
164
|
+
) -> CredFinding | None:
|
|
165
|
+
scheme = "https" if tls else "http"
|
|
166
|
+
url = f"{scheme}://{host}:{port}/"
|
|
167
|
+
target = f"{host}:{port}"
|
|
168
|
+
try:
|
|
169
|
+
async with session.get(url, allow_redirects=False) as resp:
|
|
170
|
+
status = resp.status
|
|
171
|
+
server = resp.headers.get("Server", "")
|
|
172
|
+
realm = resp.headers.get("WWW-Authenticate", "")
|
|
173
|
+
except (aiohttp.ClientError, TimeoutError, OSError):
|
|
174
|
+
return None
|
|
175
|
+
device = _looks_like_device(realm, server)
|
|
176
|
+
if status != 401:
|
|
177
|
+
if device or _looks_like_device("", server):
|
|
178
|
+
return CredFinding(
|
|
179
|
+
target=target,
|
|
180
|
+
service=f"{scheme} ({server or 'device'})",
|
|
181
|
+
kind="open-no-auth",
|
|
182
|
+
detail=f"HTTP {status} without authentication",
|
|
183
|
+
)
|
|
184
|
+
return None
|
|
185
|
+
if not device:
|
|
186
|
+
return None
|
|
187
|
+
hit = await _try_defaults(session, url, creds)
|
|
188
|
+
if hit is not None:
|
|
189
|
+
username, password = hit
|
|
190
|
+
return CredFinding(
|
|
191
|
+
target=target,
|
|
192
|
+
service=f"{scheme} ({server or realm or 'device'})",
|
|
193
|
+
kind="default-creds",
|
|
194
|
+
detail="default credentials accepted via HTTP Basic auth",
|
|
195
|
+
username=username,
|
|
196
|
+
password=password,
|
|
197
|
+
)
|
|
198
|
+
return CredFinding(
|
|
199
|
+
target=target,
|
|
200
|
+
service=f"{scheme} ({server or realm or 'device'})",
|
|
201
|
+
kind="auth-required",
|
|
202
|
+
detail="device auth required; no default credential matched",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def _try_defaults(
|
|
207
|
+
session: aiohttp.ClientSession, url: str, creds: tuple[tuple[str, str], ...]
|
|
208
|
+
) -> tuple[str, str] | None:
|
|
209
|
+
for username, password in creds:
|
|
210
|
+
auth = aiohttp.BasicAuth(username, password)
|
|
211
|
+
try:
|
|
212
|
+
async with session.get(url, auth=auth, allow_redirects=False) as resp:
|
|
213
|
+
if resp.status not in (401, 403):
|
|
214
|
+
return (username, password)
|
|
215
|
+
except (aiohttp.ClientError, TimeoutError, OSError):
|
|
216
|
+
return None
|
|
217
|
+
await asyncio.sleep(0.05)
|
|
218
|
+
return None
|