stackscan 2.2.1__tar.gz → 2.2.2__tar.gz
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-2.2.1 → stackscan-2.2.2}/PKG-INFO +4 -2
- {stackscan-2.2.1 → stackscan-2.2.2}/README.md +3 -1
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/__init__.py +1 -1
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/analyzers/__init__.py +7 -2
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/analyzers/creds.py +107 -35
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/analyzers/cve.py +47 -15
- stackscan-2.2.2/src/stackscan/analyzers/osdetect.py +127 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/analyzers/services.py +33 -20
- stackscan-2.2.2/src/stackscan/analyzers/social.py +76 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/analyzers/tech.py +120 -3
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/cli.py +117 -11
- stackscan-2.2.2/src/stackscan/data/builtin.sigdb +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/export.py +34 -1
- stackscan-2.2.2/src/stackscan/net/fingerprint.py +114 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/net/ports.py +47 -13
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/net/tls.py +20 -9
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/render.py +53 -5
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/scan.py +37 -22
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/types/__init__.py +6 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/types/output.py +67 -0
- stackscan-2.2.2/tests/test_brute.py +86 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_cve.py +48 -1
- stackscan-2.2.2/tests/test_osdetect.py +54 -0
- stackscan-2.2.2/tests/test_ports.py +97 -0
- stackscan-2.2.2/tests/test_scan.py +54 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_services.py +13 -2
- stackscan-2.2.2/tests/test_social.py +30 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_tech.py +24 -0
- stackscan-2.2.1/src/stackscan/data/builtin.sigdb +0 -0
- stackscan-2.2.1/src/stackscan/net/fingerprint.py +0 -41
- stackscan-2.2.1/tests/test_ports.py +0 -41
- {stackscan-2.2.1 → stackscan-2.2.2}/.gitignore +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/LICENSE +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/pyproject.toml +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/scripts/build_cve_db.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/__main__.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/analyzers/exposure.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/analyzers/infra.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/analyzers/security.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/config/__init__.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/config/sigdb_loader.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/config/sources.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/core/__init__.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/core/core.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/data/cve.json.gz +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/data/reekeer-logo.png +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/data/subdomains.txt +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/net/__init__.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/net/dns.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/net/geo.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/net/ipinfo.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/net/resolver.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/net/subdomains.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/net/tld.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/theme.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/types/models.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/utils/__init__.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/utils/paths.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/src/stackscan/utils/urls.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_builtin_sigdb.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_cli_helpers.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_creds.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_dns_records.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_export.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_extra.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_infra.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_sources.py +0 -0
- {stackscan-2.2.1 → stackscan-2.2.2}/tests/test_subdomains.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackscan
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.2
|
|
4
4
|
Summary: Full web stack, infrastructure, DNS, port, subdomain and CVE analyzer.
|
|
5
5
|
Project-URL: Source, https://github.com/reekeer/stackscan
|
|
6
6
|
Project-URL: Issues, https://github.com/reekeer/stackscan/issues
|
|
@@ -170,9 +170,11 @@ Below are performance benchmarks conducted on two production targets using diffe
|
|
|
170
170
|
| `--subdomains` | off | Enumerate subdomains (AXFR + wordlist + TLS SANs). |
|
|
171
171
|
| `--subdomain-limit` | `5000` | Max ranked labels to resolve (`0` = full list). |
|
|
172
172
|
| `--site-limit` | `20` | Max derived sites to analyze from discovered open ports (`0` = unlimited). |
|
|
173
|
-
| `--default-creds` | off | Bounded default-credential / open-device check. |
|
|
173
|
+
| `--default-creds` | off | Bounded default-credential / open-device check (prompts before brute-forcing). |
|
|
174
|
+
| `--full-auto` | off | Auto-accept every brute prompt on discovered devices (enables default-cred checks). |
|
|
174
175
|
| `--cred-limit` | `50` | Max default-credential pairs per device (`0` = full SecLists list). |
|
|
175
176
|
| `--cve-online` | off | Also query NVD live for detected products. |
|
|
177
|
+
| `--parse-social` | off | Extract social media and contact links from the page. |
|
|
176
178
|
| `--workers` | `350` | Parallel workers for ports/subdomains/creds. |
|
|
177
179
|
| `--concurrency` | `10` | Concurrent targets. |
|
|
178
180
|
| `--sigdb` / `--no-builtin` / `--no-sources` | – | Signature database selection. |
|
|
@@ -132,9 +132,11 @@ Below are performance benchmarks conducted on two production targets using diffe
|
|
|
132
132
|
| `--subdomains` | off | Enumerate subdomains (AXFR + wordlist + TLS SANs). |
|
|
133
133
|
| `--subdomain-limit` | `5000` | Max ranked labels to resolve (`0` = full list). |
|
|
134
134
|
| `--site-limit` | `20` | Max derived sites to analyze from discovered open ports (`0` = unlimited). |
|
|
135
|
-
| `--default-creds` | off | Bounded default-credential / open-device check. |
|
|
135
|
+
| `--default-creds` | off | Bounded default-credential / open-device check (prompts before brute-forcing). |
|
|
136
|
+
| `--full-auto` | off | Auto-accept every brute prompt on discovered devices (enables default-cred checks). |
|
|
136
137
|
| `--cred-limit` | `50` | Max default-credential pairs per device (`0` = full SecLists list). |
|
|
137
138
|
| `--cve-online` | off | Also query NVD live for detected products. |
|
|
139
|
+
| `--parse-social` | off | Extract social media and contact links from the page. |
|
|
138
140
|
| `--workers` | `350` | Parallel workers for ports/subdomains/creds. |
|
|
139
141
|
| `--concurrency` | `10` | Concurrent targets. |
|
|
140
142
|
| `--sigdb` / `--no-builtin` / `--no-sources` | – | Signature database selection. |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from stackscan.analyzers.creds import
|
|
1
|
+
from stackscan.analyzers.creds import brute_devices, detect_devices
|
|
2
2
|
from stackscan.analyzers.cve import (
|
|
3
3
|
extract_software,
|
|
4
4
|
match_cves,
|
|
@@ -8,8 +8,10 @@ from stackscan.analyzers.cve import (
|
|
|
8
8
|
)
|
|
9
9
|
from stackscan.analyzers.exposure import ExposureProbe, analyze_exposure
|
|
10
10
|
from stackscan.analyzers.infra import analyze_infra
|
|
11
|
+
from stackscan.analyzers.osdetect import detect_os
|
|
11
12
|
from stackscan.analyzers.security import analyze_security_headers
|
|
12
13
|
from stackscan.analyzers.services import classify_services, port_category
|
|
14
|
+
from stackscan.analyzers.social import parse_social
|
|
13
15
|
from stackscan.analyzers.tech import TechAnalyzer
|
|
14
16
|
|
|
15
17
|
__all__ = [
|
|
@@ -18,9 +20,12 @@ __all__ = [
|
|
|
18
20
|
"analyze_exposure",
|
|
19
21
|
"analyze_infra",
|
|
20
22
|
"analyze_security_headers",
|
|
21
|
-
"
|
|
23
|
+
"brute_devices",
|
|
24
|
+
"detect_devices",
|
|
25
|
+
"detect_os",
|
|
22
26
|
"classify_services",
|
|
23
27
|
"port_category",
|
|
28
|
+
"parse_social",
|
|
24
29
|
"extract_software",
|
|
25
30
|
"match_cves",
|
|
26
31
|
"match_cves_online",
|
|
@@ -7,7 +7,7 @@ from functools import lru_cache
|
|
|
7
7
|
|
|
8
8
|
import aiohttp
|
|
9
9
|
|
|
10
|
-
from stackscan.types import CredFinding, PortScan
|
|
10
|
+
from stackscan.types import BruteTarget, CredFinding, PortScan
|
|
11
11
|
from stackscan.utils import db_dir
|
|
12
12
|
|
|
13
13
|
_CREDS_URL = "https://raw.githubusercontent.com/danielmiessler/SecLists/master/Passwords/Default-Credentials/default-passwords.csv"
|
|
@@ -40,6 +40,27 @@ _DEVICE_KEYWORDS = (
|
|
|
40
40
|
"network camera",
|
|
41
41
|
"surveillance",
|
|
42
42
|
"uc-httpd",
|
|
43
|
+
"go2rtc",
|
|
44
|
+
"rtsp",
|
|
45
|
+
)
|
|
46
|
+
_CAMERA_KEYWORDS = (
|
|
47
|
+
"camera",
|
|
48
|
+
"ipcam",
|
|
49
|
+
"netcam",
|
|
50
|
+
"webcam",
|
|
51
|
+
"dvr",
|
|
52
|
+
"nvr",
|
|
53
|
+
"cctv",
|
|
54
|
+
"hikvision",
|
|
55
|
+
"dahua",
|
|
56
|
+
"axis",
|
|
57
|
+
"foscam",
|
|
58
|
+
"vivotek",
|
|
59
|
+
"webcamxp",
|
|
60
|
+
"network camera",
|
|
61
|
+
"surveillance",
|
|
62
|
+
"go2rtc",
|
|
63
|
+
"rtsp",
|
|
43
64
|
)
|
|
44
65
|
_BUILTIN_CREDS: tuple[tuple[str, str], ...] = (
|
|
45
66
|
("admin", "admin"),
|
|
@@ -105,6 +126,9 @@ def load_default_creds() -> tuple[tuple[str, str], ...]:
|
|
|
105
126
|
return tuple(combined)
|
|
106
127
|
|
|
107
128
|
|
|
129
|
+
_RTSP_PORTS: frozenset[int] = frozenset({554, 8554})
|
|
130
|
+
|
|
131
|
+
|
|
108
132
|
def _http_ports(scan: PortScan | None) -> list[tuple[int, bool]]:
|
|
109
133
|
if scan is None:
|
|
110
134
|
return []
|
|
@@ -113,7 +137,12 @@ def _http_ports(scan: PortScan | None) -> list[tuple[int, bool]]:
|
|
|
113
137
|
service = (port.service or "").lower()
|
|
114
138
|
if port.port in (443, 8443, 2083) or "https" in service or "ssl" in service:
|
|
115
139
|
out.append((port.port, True))
|
|
116
|
-
elif
|
|
140
|
+
elif (
|
|
141
|
+
"http" in service
|
|
142
|
+
or port.port in (80, 8080, 8000, 8081, 8888, 9000, 631, 7547)
|
|
143
|
+
or port.port in _RTSP_PORTS
|
|
144
|
+
or "rtsp" in service
|
|
145
|
+
):
|
|
117
146
|
out.append((port.port, False))
|
|
118
147
|
return out
|
|
119
148
|
|
|
@@ -123,45 +152,49 @@ def _looks_like_device(realm: str, server: str) -> bool:
|
|
|
123
152
|
return any(keyword in blob for keyword in _DEVICE_KEYWORDS)
|
|
124
153
|
|
|
125
154
|
|
|
126
|
-
|
|
155
|
+
def _is_camera(realm: str, server: str) -> bool:
|
|
156
|
+
blob = f"{realm} {server}".lower()
|
|
157
|
+
return any(keyword in blob for keyword in _CAMERA_KEYWORDS)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def detect_devices(
|
|
127
161
|
host: str,
|
|
128
162
|
scan: PortScan | None,
|
|
129
163
|
*,
|
|
130
164
|
timeout: float = 6.0,
|
|
131
165
|
insecure: bool = True,
|
|
132
166
|
workers: int = 10,
|
|
133
|
-
|
|
134
|
-
) -> list[CredFinding]:
|
|
167
|
+
) -> tuple[list[CredFinding], list[BruteTarget]]:
|
|
135
168
|
ports = _http_ports(scan)
|
|
136
169
|
if not ports:
|
|
137
|
-
return []
|
|
138
|
-
creds = await asyncio.to_thread(load_default_creds)
|
|
139
|
-
if cred_limit > 0:
|
|
140
|
-
creds = creds[:cred_limit]
|
|
170
|
+
return ([], [])
|
|
141
171
|
connector = aiohttp.TCPConnector(ssl=False, limit=max(workers, 1))
|
|
142
172
|
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
143
173
|
semaphore = asyncio.Semaphore(max(workers, 1))
|
|
144
174
|
findings: list[CredFinding] = []
|
|
175
|
+
candidates: list[BruteTarget] = []
|
|
145
176
|
async with aiohttp.ClientSession(connector=connector, timeout=client_timeout) as session:
|
|
146
177
|
|
|
147
|
-
async def
|
|
178
|
+
async def probe(port: int, tls: bool) -> None:
|
|
148
179
|
async with semaphore:
|
|
149
|
-
|
|
150
|
-
if
|
|
151
|
-
|
|
180
|
+
result = await _probe_endpoint(session, host, port, tls)
|
|
181
|
+
if isinstance(result, BruteTarget):
|
|
182
|
+
candidates.append(result)
|
|
183
|
+
elif result is not None:
|
|
184
|
+
findings.append(result)
|
|
152
185
|
|
|
153
|
-
await asyncio.gather(*(
|
|
154
|
-
findings.sort(key=lambda f:
|
|
155
|
-
|
|
186
|
+
await asyncio.gather(*(probe(port, tls) for port, tls in ports))
|
|
187
|
+
findings.sort(key=lambda f: f.target)
|
|
188
|
+
candidates.sort(key=lambda c: (c.host, c.port))
|
|
189
|
+
return (findings, candidates)
|
|
156
190
|
|
|
157
191
|
|
|
158
|
-
async def
|
|
192
|
+
async def _probe_endpoint(
|
|
159
193
|
session: aiohttp.ClientSession,
|
|
160
194
|
host: str,
|
|
161
195
|
port: int,
|
|
162
196
|
tls: bool,
|
|
163
|
-
|
|
164
|
-
) -> CredFinding | None:
|
|
197
|
+
) -> CredFinding | BruteTarget | None:
|
|
165
198
|
scheme = "https" if tls else "http"
|
|
166
199
|
url = f"{scheme}://{host}:{port}/"
|
|
167
200
|
target = f"{host}:{port}"
|
|
@@ -172,7 +205,7 @@ async def _check_endpoint(
|
|
|
172
205
|
realm = resp.headers.get("WWW-Authenticate", "")
|
|
173
206
|
except (aiohttp.ClientError, TimeoutError, OSError):
|
|
174
207
|
return None
|
|
175
|
-
device = _looks_like_device(realm, server)
|
|
208
|
+
device = _looks_like_device(realm, server) or port in _RTSP_PORTS
|
|
176
209
|
if status != 401:
|
|
177
210
|
if device or _looks_like_device("", server):
|
|
178
211
|
return CredFinding(
|
|
@@ -184,25 +217,64 @@ async def _check_endpoint(
|
|
|
184
217
|
return None
|
|
185
218
|
if not device:
|
|
186
219
|
return None
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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,
|
|
220
|
+
return BruteTarget(
|
|
221
|
+
host=host,
|
|
222
|
+
port=port,
|
|
223
|
+
tls=tls,
|
|
200
224
|
service=f"{scheme} ({server or realm or 'device'})",
|
|
201
|
-
|
|
202
|
-
detail="device auth required; no default credential matched",
|
|
225
|
+
is_camera=_is_camera(realm, server) or port in _RTSP_PORTS,
|
|
203
226
|
)
|
|
204
227
|
|
|
205
228
|
|
|
229
|
+
async def brute_devices(
|
|
230
|
+
targets: list[BruteTarget],
|
|
231
|
+
*,
|
|
232
|
+
timeout: float = 6.0,
|
|
233
|
+
insecure: bool = True,
|
|
234
|
+
workers: int = 10,
|
|
235
|
+
cred_limit: int = 100,
|
|
236
|
+
) -> list[CredFinding]:
|
|
237
|
+
if not targets:
|
|
238
|
+
return []
|
|
239
|
+
creds = await asyncio.to_thread(load_default_creds)
|
|
240
|
+
if cred_limit > 0:
|
|
241
|
+
creds = creds[:cred_limit]
|
|
242
|
+
connector = aiohttp.TCPConnector(ssl=False, limit=max(workers, 1))
|
|
243
|
+
client_timeout = aiohttp.ClientTimeout(total=timeout)
|
|
244
|
+
semaphore = asyncio.Semaphore(max(workers, 1))
|
|
245
|
+
findings: list[CredFinding] = []
|
|
246
|
+
async with aiohttp.ClientSession(connector=connector, timeout=client_timeout) as session:
|
|
247
|
+
|
|
248
|
+
async def run(target: BruteTarget) -> None:
|
|
249
|
+
async with semaphore:
|
|
250
|
+
hit = await _try_defaults(session, target.url, creds)
|
|
251
|
+
if hit is not None:
|
|
252
|
+
username, password = hit
|
|
253
|
+
findings.append(
|
|
254
|
+
CredFinding(
|
|
255
|
+
target=target.target,
|
|
256
|
+
service=target.service,
|
|
257
|
+
kind="default-creds",
|
|
258
|
+
detail="default credentials accepted via HTTP Basic auth",
|
|
259
|
+
username=username,
|
|
260
|
+
password=password,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
else:
|
|
264
|
+
findings.append(
|
|
265
|
+
CredFinding(
|
|
266
|
+
target=target.target,
|
|
267
|
+
service=target.service,
|
|
268
|
+
kind="auth-required",
|
|
269
|
+
detail="device auth required; no default credential matched",
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
await asyncio.gather(*(run(target) for target in targets))
|
|
274
|
+
findings.sort(key=lambda f: (f.kind != "default-creds", f.target))
|
|
275
|
+
return findings
|
|
276
|
+
|
|
277
|
+
|
|
206
278
|
async def _try_defaults(
|
|
207
279
|
session: aiohttp.ClientSession, url: str, creds: tuple[tuple[str, str], ...]
|
|
208
280
|
) -> tuple[str, str] | None:
|
|
@@ -12,6 +12,16 @@ from typing import Any, cast
|
|
|
12
12
|
from stackscan.types import CveMatch, Headers, PortScan, Software
|
|
13
13
|
|
|
14
14
|
CveEntry = dict[str, Any]
|
|
15
|
+
_BACKPORT_DISTRO_RE = re.compile(
|
|
16
|
+
r"(0?ubuntu0?[._]\d[\d.]+)"
|
|
17
|
+
r"|\b(ubuntu|debian|centos|rhel|red\s*hat|fedora|amzn|amazon|rocky|almalinux|alpine|raspbian|suse|opensuse)\b"
|
|
18
|
+
r"|(\+deb\d+u?\d*)"
|
|
19
|
+
r"|(-\d+ubuntu\d+(?![\d._]))"
|
|
20
|
+
r"|(el\d+)"
|
|
21
|
+
r"|(\.fc\d+)"
|
|
22
|
+
r"|(~bpo\d+\+[\w]+)",
|
|
23
|
+
re.I,
|
|
24
|
+
)
|
|
15
25
|
CveDb = dict[str, list[CveEntry]]
|
|
16
26
|
_NAME_MAP: dict[str, str] = {
|
|
17
27
|
"nginx": "nginx",
|
|
@@ -109,6 +119,11 @@ def load_cve_db() -> CveDb:
|
|
|
109
119
|
return cast("CveDb", products)
|
|
110
120
|
|
|
111
121
|
|
|
122
|
+
def _distro_tag(text: str) -> str:
|
|
123
|
+
match = _BACKPORT_DISTRO_RE.search(text)
|
|
124
|
+
return match.group(0) if match else ""
|
|
125
|
+
|
|
126
|
+
|
|
112
127
|
def _parse_version(value: str) -> tuple[tuple[int, str], ...]:
|
|
113
128
|
parts: list[tuple[int, str]] = []
|
|
114
129
|
for chunk in re.split("[.\\-_]", value.strip()):
|
|
@@ -140,10 +155,12 @@ def _in_range(version: str, rng: dict[str, str]) -> bool:
|
|
|
140
155
|
return bool(rng)
|
|
141
156
|
|
|
142
157
|
|
|
143
|
-
def _tokens(value: str, source: str, location: str) -> list[Software]:
|
|
158
|
+
def _tokens(value: str, source: str, location: str, os: str = "") -> list[Software]:
|
|
144
159
|
found: list[Software] = []
|
|
145
160
|
for name, version in _TOKEN_RE.findall(value):
|
|
146
|
-
found.append(
|
|
161
|
+
found.append(
|
|
162
|
+
Software(name=name.lower(), version=version, source=source, location=location, os=os)
|
|
163
|
+
)
|
|
147
164
|
return found
|
|
148
165
|
|
|
149
166
|
|
|
@@ -159,7 +176,8 @@ def extract_software(headers: Headers, body: str, location: str = "") -> list[So
|
|
|
159
176
|
|
|
160
177
|
server = headers.get("server")
|
|
161
178
|
if server:
|
|
162
|
-
|
|
179
|
+
server_os = _distro_tag(server)
|
|
180
|
+
for item in _tokens(server, "header:server", location, os=server_os):
|
|
163
181
|
add(item)
|
|
164
182
|
powered = headers.get("x-powered-by")
|
|
165
183
|
if powered:
|
|
@@ -197,7 +215,11 @@ def software_from_ports(scan: PortScan | None) -> list[Software]:
|
|
|
197
215
|
if ssh:
|
|
198
216
|
out.append(
|
|
199
217
|
Software(
|
|
200
|
-
name="openssh",
|
|
218
|
+
name="openssh",
|
|
219
|
+
version=ssh.group(1),
|
|
220
|
+
source="port-banner",
|
|
221
|
+
location=location,
|
|
222
|
+
os=port.os,
|
|
201
223
|
)
|
|
202
224
|
)
|
|
203
225
|
continue
|
|
@@ -208,6 +230,7 @@ def software_from_ports(scan: PortScan | None) -> list[Software]:
|
|
|
208
230
|
version=port.version,
|
|
209
231
|
source="port-banner",
|
|
210
232
|
location=location,
|
|
233
|
+
os=port.os,
|
|
211
234
|
)
|
|
212
235
|
)
|
|
213
236
|
return out
|
|
@@ -218,19 +241,16 @@ _SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
|
218
241
|
|
|
219
242
|
|
|
220
243
|
def _confidence(version: str, rng: dict[str, str], source: str) -> int:
|
|
221
|
-
score = 55
|
|
222
244
|
comps = len([c for c in re.split("[.\\-_]", version) if c[:1].isdigit()])
|
|
223
|
-
if comps >= 3:
|
|
224
|
-
score += 20
|
|
225
|
-
elif comps >= 2:
|
|
226
|
-
score += 12
|
|
227
245
|
bounded = ("start_incl" in rng or "start_excl" in rng) and (
|
|
228
246
|
"end_incl" in rng or "end_excl" in rng
|
|
229
247
|
)
|
|
230
|
-
|
|
231
|
-
if
|
|
232
|
-
|
|
233
|
-
|
|
248
|
+
authoritative = source in _AUTHORITATIVE
|
|
249
|
+
if authoritative and bounded and comps >= 3:
|
|
250
|
+
return 98
|
|
251
|
+
if (authoritative or bounded) and comps >= 2:
|
|
252
|
+
return 91
|
|
253
|
+
return 85
|
|
234
254
|
|
|
235
255
|
|
|
236
256
|
@dataclass
|
|
@@ -245,6 +265,8 @@ class _CveAgg:
|
|
|
245
265
|
url: str | None
|
|
246
266
|
locations: set[str] = field(default_factory=set[str])
|
|
247
267
|
sources: set[str] = field(default_factory=set[str])
|
|
268
|
+
unconfirmed: bool = False
|
|
269
|
+
caveat: str = ""
|
|
248
270
|
|
|
249
271
|
|
|
250
272
|
def _match_entries(
|
|
@@ -256,13 +278,15 @@ def _match_entries(
|
|
|
256
278
|
version = item.version
|
|
257
279
|
if not version:
|
|
258
280
|
return
|
|
281
|
+
backported = bool(item.os)
|
|
282
|
+
caveat = "distro backport likely — patchlevel not in banner" if backported else ""
|
|
259
283
|
for entry in entries:
|
|
260
284
|
ranges = cast("list[dict[str, str]]", entry.get("ranges") or [])
|
|
261
285
|
hit_rng = next((r for r in ranges if _in_range(version, r)), None)
|
|
262
286
|
if hit_rng is None:
|
|
263
287
|
continue
|
|
264
288
|
cve_id = str(entry["id"])
|
|
265
|
-
confidence = _confidence(version, hit_rng, item.source)
|
|
289
|
+
confidence = 40 if backported else _confidence(version, hit_rng, item.source)
|
|
266
290
|
record = agg.get(cve_id)
|
|
267
291
|
if record is None:
|
|
268
292
|
record = _CveAgg(
|
|
@@ -274,13 +298,19 @@ def _match_entries(
|
|
|
274
298
|
confidence=confidence,
|
|
275
299
|
summary=str(entry.get("summary", "")),
|
|
276
300
|
url=entry.get("url"),
|
|
301
|
+
unconfirmed=backported,
|
|
302
|
+
caveat=caveat,
|
|
277
303
|
)
|
|
278
304
|
agg[cve_id] = record
|
|
279
305
|
if item.location:
|
|
280
306
|
record.locations.add(item.location)
|
|
281
307
|
if item.source:
|
|
282
308
|
record.sources.add(item.source)
|
|
283
|
-
if
|
|
309
|
+
if backported:
|
|
310
|
+
record.unconfirmed = True
|
|
311
|
+
record.caveat = caveat
|
|
312
|
+
record.confidence = min(record.confidence, 40)
|
|
313
|
+
elif confidence > record.confidence:
|
|
284
314
|
record.confidence = confidence
|
|
285
315
|
record.version = version
|
|
286
316
|
|
|
@@ -298,6 +328,8 @@ def _agg_to_matches(agg: dict[str, _CveAgg]) -> list[CveMatch]:
|
|
|
298
328
|
url=r.url,
|
|
299
329
|
locations=tuple(sorted(r.locations)),
|
|
300
330
|
sources=tuple(sorted(r.sources)),
|
|
331
|
+
unconfirmed=r.unconfirmed,
|
|
332
|
+
caveat=r.caveat,
|
|
301
333
|
)
|
|
302
334
|
for r in agg.values()
|
|
303
335
|
]
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
|
|
5
|
+
from stackscan.types import OsFinding, ScanReport
|
|
6
|
+
|
|
7
|
+
_WINDOWS_SERVICES: frozenset[str] = frozenset(
|
|
8
|
+
{
|
|
9
|
+
"microsoft-iis",
|
|
10
|
+
"ms-sql",
|
|
11
|
+
"msrpc",
|
|
12
|
+
"netbios",
|
|
13
|
+
"ms-wbt",
|
|
14
|
+
"ms-wbt-server",
|
|
15
|
+
"wsman",
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_windows_service(service: str | None) -> bool:
|
|
21
|
+
if not service:
|
|
22
|
+
return False
|
|
23
|
+
lower = service.lower()
|
|
24
|
+
return lower in _WINDOWS_SERVICES or lower.startswith("ms-")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _normalize_os(name: str) -> str:
|
|
28
|
+
lower = name.lower()
|
|
29
|
+
if "win64" in lower or "win32" in lower or "windows" in lower:
|
|
30
|
+
return "Windows"
|
|
31
|
+
if "ubuntu" in lower:
|
|
32
|
+
return "Ubuntu"
|
|
33
|
+
if "debian" in lower:
|
|
34
|
+
return "Debian"
|
|
35
|
+
if "centos" in lower:
|
|
36
|
+
return "CentOS"
|
|
37
|
+
if "rhel/centos" in lower:
|
|
38
|
+
return "RHEL/CentOS"
|
|
39
|
+
if "rhel" in lower or "red hat" in lower:
|
|
40
|
+
return "Red Hat"
|
|
41
|
+
if "fedora" in lower:
|
|
42
|
+
return "Fedora"
|
|
43
|
+
if "amazon" in lower or "amzn" in lower:
|
|
44
|
+
return "Amazon Linux"
|
|
45
|
+
if "alpine" in lower:
|
|
46
|
+
return "Alpine"
|
|
47
|
+
if "rocky" in lower:
|
|
48
|
+
return "Rocky Linux"
|
|
49
|
+
if "alma" in lower:
|
|
50
|
+
return "AlmaLinux"
|
|
51
|
+
if "unix" in lower:
|
|
52
|
+
return "Unix"
|
|
53
|
+
return name.strip().title() if name else ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _collect_votes(report: ScanReport) -> dict[str, list[tuple[str, str]]]:
|
|
57
|
+
votes: dict[str, list[tuple[str, str]]] = {}
|
|
58
|
+
|
|
59
|
+
def add(host: str, os: str, source: str) -> None:
|
|
60
|
+
if not host or not os:
|
|
61
|
+
return
|
|
62
|
+
normalized = _normalize_os(os)
|
|
63
|
+
if not normalized:
|
|
64
|
+
return
|
|
65
|
+
votes.setdefault(host, []).append((normalized, source))
|
|
66
|
+
|
|
67
|
+
primary_host = report.network.host if report.network else ""
|
|
68
|
+
|
|
69
|
+
for software in report.software:
|
|
70
|
+
if software.os:
|
|
71
|
+
add(primary_host, software.os, software.source)
|
|
72
|
+
|
|
73
|
+
if report.ports is not None:
|
|
74
|
+
for port in report.ports.ports:
|
|
75
|
+
if not port.host:
|
|
76
|
+
continue
|
|
77
|
+
if port.os:
|
|
78
|
+
add(port.host, port.os, f"port {port.port}")
|
|
79
|
+
if _is_windows_service(port.service):
|
|
80
|
+
add(port.host, "Windows", f"port {port.port}")
|
|
81
|
+
|
|
82
|
+
for site in report.site_findings:
|
|
83
|
+
from stackscan.utils import host_of
|
|
84
|
+
|
|
85
|
+
host = host_of(site.url)
|
|
86
|
+
for software in site.software:
|
|
87
|
+
if software.os:
|
|
88
|
+
add(host, software.os, software.source)
|
|
89
|
+
|
|
90
|
+
return votes
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def detect_os(report: ScanReport) -> list[OsFinding]:
|
|
94
|
+
votes = _collect_votes(report)
|
|
95
|
+
findings: list[OsFinding] = []
|
|
96
|
+
for host, pairs in votes.items():
|
|
97
|
+
if not pairs:
|
|
98
|
+
continue
|
|
99
|
+
counter = Counter(os for os, _ in pairs)
|
|
100
|
+
total = sum(counter.values())
|
|
101
|
+
if not total:
|
|
102
|
+
continue
|
|
103
|
+
winner, winner_count = counter.most_common(1)[0]
|
|
104
|
+
sources = ", ".join(sorted(set(src for os, src in pairs if os == winner)))
|
|
105
|
+
by_source: dict[str, list[str]] = {}
|
|
106
|
+
for os, src in pairs:
|
|
107
|
+
by_source.setdefault(src, []).append(os)
|
|
108
|
+
dominant_source = max(by_source, key=lambda s: len(by_source[s]))
|
|
109
|
+
if dominant_source == "port-banner" or dominant_source.startswith(("header:", "meta:", "script")):
|
|
110
|
+
category = "banner"
|
|
111
|
+
elif dominant_source.startswith("port "):
|
|
112
|
+
category = "network"
|
|
113
|
+
else:
|
|
114
|
+
category = "banner"
|
|
115
|
+
service = dominant_source
|
|
116
|
+
findings.append(
|
|
117
|
+
OsFinding(
|
|
118
|
+
host=host,
|
|
119
|
+
os=winner,
|
|
120
|
+
category=category,
|
|
121
|
+
service=service,
|
|
122
|
+
source=sources,
|
|
123
|
+
confidence=winner_count / total,
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
findings.sort(key=lambda f: (f.host, -f.confidence))
|
|
127
|
+
return findings
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from stackscan.types import ScanReport, ServiceFinding, Technology
|
|
3
|
+
from stackscan.types import Port, ScanReport, ServiceFinding, Technology
|
|
4
4
|
|
|
5
5
|
_DB_PORTS: dict[int, str] = {
|
|
6
6
|
3306: "MySQL",
|
|
@@ -129,7 +129,7 @@ _SECURITY_TECHS: dict[str, str] = {
|
|
|
129
129
|
}
|
|
130
130
|
|
|
131
131
|
_SEVERITY: dict[str, str] = {
|
|
132
|
-
"database": "
|
|
132
|
+
"database": "MEDIUM",
|
|
133
133
|
"admin-panel": "HIGH",
|
|
134
134
|
"remote-access": "HIGH",
|
|
135
135
|
"camera": "HIGH",
|
|
@@ -176,33 +176,46 @@ def _service_from_tech(tech: Technology) -> ServiceFinding | None:
|
|
|
176
176
|
return None
|
|
177
177
|
|
|
178
178
|
|
|
179
|
-
def
|
|
180
|
-
if
|
|
179
|
+
def _database_severity(state: str) -> str:
|
|
180
|
+
if state == "open-no-auth":
|
|
181
|
+
return "CRITICAL"
|
|
182
|
+
if state == "auth-refused":
|
|
183
|
+
return "LOW"
|
|
184
|
+
return _SEVERITY["database"]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _service_from_port(port: Port) -> ServiceFinding | None:
|
|
188
|
+
number = port.port
|
|
189
|
+
if number in _DB_PORTS:
|
|
190
|
+
severity = _database_severity(port.state)
|
|
191
|
+
evidence = f"port {number}/tcp"
|
|
192
|
+
if port.state == "auth-refused":
|
|
193
|
+
evidence += "; connection refused by host ACL"
|
|
181
194
|
return ServiceFinding(
|
|
182
|
-
name=_DB_PORTS[
|
|
195
|
+
name=_DB_PORTS[number],
|
|
183
196
|
kind="database",
|
|
184
|
-
evidence=
|
|
185
|
-
severity=
|
|
197
|
+
evidence=evidence,
|
|
198
|
+
severity=severity,
|
|
186
199
|
)
|
|
187
|
-
if
|
|
200
|
+
if number in _REMOTE_PORTS:
|
|
188
201
|
return ServiceFinding(
|
|
189
|
-
name=_REMOTE_PORTS[
|
|
202
|
+
name=_REMOTE_PORTS[number],
|
|
190
203
|
kind="remote-access",
|
|
191
|
-
evidence=f"port {
|
|
204
|
+
evidence=f"port {number}/tcp",
|
|
192
205
|
severity=_SEVERITY["remote-access"],
|
|
193
206
|
)
|
|
194
|
-
if
|
|
207
|
+
if number in _CAMERA_PORTS:
|
|
195
208
|
return ServiceFinding(
|
|
196
|
-
name=_CAMERA_PORTS[
|
|
209
|
+
name=_CAMERA_PORTS[number],
|
|
197
210
|
kind="camera",
|
|
198
|
-
evidence=f"port {
|
|
211
|
+
evidence=f"port {number}/tcp",
|
|
199
212
|
severity=_SEVERITY["camera"],
|
|
200
213
|
)
|
|
201
|
-
if
|
|
214
|
+
if number in _MESSAGE_PORTS:
|
|
202
215
|
return ServiceFinding(
|
|
203
|
-
name=_MESSAGE_PORTS[
|
|
216
|
+
name=_MESSAGE_PORTS[number],
|
|
204
217
|
kind="messaging",
|
|
205
|
-
evidence=f"port {
|
|
218
|
+
evidence=f"port {number}/tcp",
|
|
206
219
|
severity=_SEVERITY["messaging"],
|
|
207
220
|
)
|
|
208
221
|
return None
|
|
@@ -214,10 +227,10 @@ _WEB_PORTS: frozenset[int] = frozenset(
|
|
|
214
227
|
_WINDOWS_PORTS: frozenset[int] = frozenset({111, 135, 139, 445, 5985})
|
|
215
228
|
|
|
216
229
|
|
|
217
|
-
def port_category(port: int, service: str | None) -> tuple[str, str]:
|
|
230
|
+
def port_category(port: int, service: str | None, state: str = "open") -> tuple[str, str]:
|
|
218
231
|
|
|
219
232
|
if port in _DB_PORTS:
|
|
220
|
-
return ("database",
|
|
233
|
+
return ("database", _database_severity(state))
|
|
221
234
|
if port in _REMOTE_PORTS or port == 2222:
|
|
222
235
|
return ("remote-access", _SEVERITY["remote-access"])
|
|
223
236
|
if port in _CAMERA_PORTS:
|
|
@@ -242,7 +255,7 @@ def port_category(port: int, service: str | None) -> tuple[str, str]:
|
|
|
242
255
|
if any(k in svc for k in ("smtp", "imap", "pop", "mqtt")):
|
|
243
256
|
return ("mail", "MEDIUM")
|
|
244
257
|
if any(k in svc for k in ("mysql", "postgres", "redis", "mongo", "sql")):
|
|
245
|
-
return ("database",
|
|
258
|
+
return ("database", _database_severity(state))
|
|
246
259
|
if "ftp" in svc:
|
|
247
260
|
return ("file-transfer", "MEDIUM")
|
|
248
261
|
return ("other", "INFO")
|
|
@@ -265,7 +278,7 @@ def classify_services(report: ScanReport) -> list[ServiceFinding]:
|
|
|
265
278
|
scan = report.ports
|
|
266
279
|
if scan is not None:
|
|
267
280
|
for port in scan.ports:
|
|
268
|
-
finding = _service_from_port(port
|
|
281
|
+
finding = _service_from_port(port)
|
|
269
282
|
if finding is None:
|
|
270
283
|
continue
|
|
271
284
|
key = (finding.name.lower(), finding.kind)
|