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.
Files changed (45) hide show
  1. stackscan/__init__.py +5 -0
  2. stackscan/__main__.py +4 -0
  3. stackscan/analyzers/__init__.py +29 -0
  4. stackscan/analyzers/creds.py +218 -0
  5. stackscan/analyzers/cve.py +458 -0
  6. stackscan/analyzers/exposure.py +73 -0
  7. stackscan/analyzers/infra.py +114 -0
  8. stackscan/analyzers/security.py +26 -0
  9. stackscan/analyzers/services.py +285 -0
  10. stackscan/analyzers/tech.py +178 -0
  11. stackscan/cli.py +750 -0
  12. stackscan/config/__init__.py +19 -0
  13. stackscan/config/sigdb_loader.py +74 -0
  14. stackscan/config/sources.py +238 -0
  15. stackscan/core/__init__.py +3 -0
  16. stackscan/core/core.py +60 -0
  17. stackscan/data/builtin.sigdb +0 -0
  18. stackscan/data/cve.json.gz +0 -0
  19. stackscan/data/reekeer-logo.png +0 -0
  20. stackscan/data/subdomains.txt +522 -0
  21. stackscan/export.py +574 -0
  22. stackscan/net/__init__.py +22 -0
  23. stackscan/net/dns.py +168 -0
  24. stackscan/net/fingerprint.py +41 -0
  25. stackscan/net/geo.py +70 -0
  26. stackscan/net/ipinfo.py +94 -0
  27. stackscan/net/ports.py +219 -0
  28. stackscan/net/resolver.py +54 -0
  29. stackscan/net/subdomains.py +457 -0
  30. stackscan/net/tld.py +126 -0
  31. stackscan/net/tls.py +78 -0
  32. stackscan/render.py +541 -0
  33. stackscan/scan.py +545 -0
  34. stackscan/theme.py +23 -0
  35. stackscan/types/__init__.py +43 -0
  36. stackscan/types/models.py +18 -0
  37. stackscan/types/output.py +367 -0
  38. stackscan/utils/__init__.py +4 -0
  39. stackscan/utils/paths.py +10 -0
  40. stackscan/utils/urls.py +39 -0
  41. stackscan-2.1.0.dist-info/METADATA +341 -0
  42. stackscan-2.1.0.dist-info/RECORD +45 -0
  43. stackscan-2.1.0.dist-info/WHEEL +4 -0
  44. stackscan-2.1.0.dist-info/entry_points.txt +2 -0
  45. stackscan-2.1.0.dist-info/licenses/LICENSE +18 -0
stackscan/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .core import StackscanSession
2
+ from .utils import normalize_url
3
+
4
+ __all__ = ["StackscanSession", "__version__", "normalize_url"]
5
+ __version__ = "2.1.0"
stackscan/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from stackscan.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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