stackscan 2.2.1__tar.gz → 2.3.0__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.3.0}/PKG-INFO +7 -4
- {stackscan-2.2.1 → stackscan-2.3.0}/README.md +6 -3
- {stackscan-2.2.1 → stackscan-2.3.0}/scripts/build_cve_db.py +12 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/__init__.py +1 -1
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/__init__.py +7 -2
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/creds.py +107 -35
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/cve.py +108 -21
- stackscan-2.3.0/src/stackscan/analyzers/generic.py +271 -0
- stackscan-2.3.0/src/stackscan/analyzers/osdetect.py +127 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/services.py +38 -35
- stackscan-2.3.0/src/stackscan/analyzers/social.py +76 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/tech.py +165 -4
- stackscan-2.3.0/src/stackscan/analyzers/vibe.py +248 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/cli.py +227 -80
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/core/core.py +14 -2
- stackscan-2.3.0/src/stackscan/data/builtin.sigdb +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/data/subdomains.txt +32 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/export.py +34 -1
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/dns.py +16 -9
- stackscan-2.3.0/src/stackscan/net/fingerprint.py +114 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/ipinfo.py +13 -6
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/ports.py +47 -13
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/subdomains.py +71 -4
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/tls.py +45 -22
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/render.py +218 -84
- stackscan-2.3.0/src/stackscan/scan.py +1039 -0
- stackscan-2.3.0/src/stackscan/scanners/__init__.py +0 -0
- stackscan-2.3.0/src/stackscan/scanners/isp_blocked.py +133 -0
- stackscan-2.3.0/src/stackscan/scanners/secrets.py +100 -0
- stackscan-2.3.0/src/stackscan/scanners/takeover.py +214 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/types/__init__.py +10 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/types/output.py +115 -0
- stackscan-2.3.0/tests/test_brute.py +86 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_cli_helpers.py +3 -0
- stackscan-2.3.0/tests/test_cve.py +229 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_extra.py +5 -5
- stackscan-2.3.0/tests/test_osdetect.py +54 -0
- stackscan-2.3.0/tests/test_ports.py +97 -0
- stackscan-2.3.0/tests/test_scan.py +54 -0
- stackscan-2.3.0/tests/test_scanners.py +97 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_services.py +27 -2
- stackscan-2.3.0/tests/test_social.py +30 -0
- stackscan-2.3.0/tests/test_tech.py +224 -0
- stackscan-2.3.0/tests/test_vhost.py +133 -0
- stackscan-2.3.0/tests/test_vibe.py +38 -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/src/stackscan/scan.py +0 -545
- stackscan-2.2.1/tests/test_cve.py +0 -73
- stackscan-2.2.1/tests/test_ports.py +0 -41
- stackscan-2.2.1/tests/test_tech.py +0 -47
- {stackscan-2.2.1 → stackscan-2.3.0}/.gitignore +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/LICENSE +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/pyproject.toml +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/__main__.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/exposure.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/infra.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/analyzers/security.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/config/__init__.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/config/sigdb_loader.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/config/sources.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/core/__init__.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/data/cve.json.gz +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/data/reekeer-logo.png +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/__init__.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/geo.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/resolver.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/net/tld.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/theme.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/types/models.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/utils/__init__.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/utils/paths.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/src/stackscan/utils/urls.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_builtin_sigdb.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_creds.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_dns_records.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_export.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_infra.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_sources.py +0 -0
- {stackscan-2.2.1 → stackscan-2.3.0}/tests/test_subdomains.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackscan
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
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
|
|
@@ -117,7 +117,7 @@ stackscan --cve-online example.com
|
|
|
117
117
|
|
|
118
118
|
# Speed / coverage knobs
|
|
119
119
|
stackscan --full --workers 500 --subdomain-limit 10000 example.com
|
|
120
|
-
stackscan --full --site-limit
|
|
120
|
+
stackscan --full --site-limit 20 --workers 200 example.com
|
|
121
121
|
|
|
122
122
|
# JSON (full detail, includes timing) or a compact table
|
|
123
123
|
stackscan --json example.com
|
|
@@ -169,10 +169,13 @@ Below are performance benchmarks conducted on two production targets using diffe
|
|
|
169
169
|
| `--ports` / `--no-nmap` | off | Active port scan (nmap, else Python) / force the Python scanner. |
|
|
170
170
|
| `--subdomains` | off | Enumerate subdomains (AXFR + wordlist + TLS SANs). |
|
|
171
171
|
| `--subdomain-limit` | `5000` | Max ranked labels to resolve (`0` = full list). |
|
|
172
|
-
| `--site-limit` | `
|
|
173
|
-
| `--
|
|
172
|
+
| `--site-limit` | `50` | Max derived sites to analyze from discovered open ports (`0` = unlimited). |
|
|
173
|
+
| `--cve-min-confidence` | `50` | Hide CVE matches with confidence below N (`0` shows all). |
|
|
174
|
+
| `--default-creds` | off | Bounded default-credential / open-device check (prompts before brute-forcing). |
|
|
175
|
+
| `--full-auto` | off | Auto-accept every brute prompt on discovered devices (enables default-cred checks). |
|
|
174
176
|
| `--cred-limit` | `50` | Max default-credential pairs per device (`0` = full SecLists list). |
|
|
175
177
|
| `--cve-online` | off | Also query NVD live for detected products. |
|
|
178
|
+
| `--parse-social` | off | Extract social media and contact links from the page. |
|
|
176
179
|
| `--workers` | `350` | Parallel workers for ports/subdomains/creds. |
|
|
177
180
|
| `--concurrency` | `10` | Concurrent targets. |
|
|
178
181
|
| `--sigdb` / `--no-builtin` / `--no-sources` | – | Signature database selection. |
|
|
@@ -79,7 +79,7 @@ stackscan --cve-online example.com
|
|
|
79
79
|
|
|
80
80
|
# Speed / coverage knobs
|
|
81
81
|
stackscan --full --workers 500 --subdomain-limit 10000 example.com
|
|
82
|
-
stackscan --full --site-limit
|
|
82
|
+
stackscan --full --site-limit 20 --workers 200 example.com
|
|
83
83
|
|
|
84
84
|
# JSON (full detail, includes timing) or a compact table
|
|
85
85
|
stackscan --json example.com
|
|
@@ -131,10 +131,13 @@ Below are performance benchmarks conducted on two production targets using diffe
|
|
|
131
131
|
| `--ports` / `--no-nmap` | off | Active port scan (nmap, else Python) / force the Python scanner. |
|
|
132
132
|
| `--subdomains` | off | Enumerate subdomains (AXFR + wordlist + TLS SANs). |
|
|
133
133
|
| `--subdomain-limit` | `5000` | Max ranked labels to resolve (`0` = full list). |
|
|
134
|
-
| `--site-limit` | `
|
|
135
|
-
| `--
|
|
134
|
+
| `--site-limit` | `50` | Max derived sites to analyze from discovered open ports (`0` = unlimited). |
|
|
135
|
+
| `--cve-min-confidence` | `50` | Hide CVE matches with confidence below N (`0` shows all). |
|
|
136
|
+
| `--default-creds` | off | Bounded default-credential / open-device check (prompts before brute-forcing). |
|
|
137
|
+
| `--full-auto` | off | Auto-accept every brute prompt on discovered devices (enables default-cred checks). |
|
|
136
138
|
| `--cred-limit` | `50` | Max default-credential pairs per device (`0` = full SecLists list). |
|
|
137
139
|
| `--cve-online` | off | Also query NVD live for detected products. |
|
|
140
|
+
| `--parse-social` | off | Extract social media and contact links from the page. |
|
|
138
141
|
| `--workers` | `350` | Parallel workers for ports/subdomains/creds. |
|
|
139
142
|
| `--concurrency` | `10` | Concurrent targets. |
|
|
140
143
|
| `--sigdb` / `--no-builtin` / `--no-sources` | – | Signature database selection. |
|
|
@@ -138,6 +138,16 @@ def ranges_for(cve: dict[str, Any], needle: str) -> list[dict[str, str]]:
|
|
|
138
138
|
return out
|
|
139
139
|
|
|
140
140
|
|
|
141
|
+
def _skip_entry(cve: dict[str, Any]) -> bool:
|
|
142
|
+
status = str(cve.get("vulnStatus", "")).upper()
|
|
143
|
+
if status in {"REJECTED", "REPLACED", "DEPRECATED"}:
|
|
144
|
+
return True
|
|
145
|
+
descriptions = cve.get("descriptions") or []
|
|
146
|
+
if not english_summary(descriptions):
|
|
147
|
+
return True
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
|
|
141
151
|
def collect(product: str, vendor_product: str, throttle: float) -> list[dict[str, Any]]:
|
|
142
152
|
needle = ":" + vendor_product + ":"
|
|
143
153
|
entries: list[dict[str, Any]] = []
|
|
@@ -149,6 +159,8 @@ def collect(product: str, vendor_product: str, throttle: float) -> list[dict[str
|
|
|
149
159
|
vulns = payload.get("vulnerabilities") or []
|
|
150
160
|
for wrapper in vulns:
|
|
151
161
|
cve = wrapper["cve"]
|
|
162
|
+
if _skip_entry(cve):
|
|
163
|
+
continue
|
|
152
164
|
ranges = ranges_for(cve, needle)
|
|
153
165
|
if not ranges:
|
|
154
166
|
continue
|
|
@@ -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:
|
|
@@ -9,9 +9,20 @@ from functools import lru_cache
|
|
|
9
9
|
from importlib import resources
|
|
10
10
|
from typing import Any, cast
|
|
11
11
|
|
|
12
|
+
from stackscan.analyzers.generic import SERVER_NAMES, extract_generic_software, is_commit_hash
|
|
12
13
|
from stackscan.types import CveMatch, Headers, PortScan, Software
|
|
13
14
|
|
|
14
15
|
CveEntry = dict[str, Any]
|
|
16
|
+
_BACKPORT_DISTRO_RE = re.compile(
|
|
17
|
+
r"(0?ubuntu0?[._]\d[\d.]+)"
|
|
18
|
+
r"|\b(ubuntu|debian|centos|rhel|red\s*hat|fedora|amzn|amazon|rocky|almalinux|alpine|raspbian|suse|opensuse|oracle|sles)\b"
|
|
19
|
+
r"|(\+deb\d+u?\d*)"
|
|
20
|
+
r"|(-\d+ubuntu\d+(?![\d._]))"
|
|
21
|
+
r"|(el\d+)"
|
|
22
|
+
r"|(\.fc\d+)"
|
|
23
|
+
r"|(~bpo\d+\+[\w]+)",
|
|
24
|
+
re.I,
|
|
25
|
+
)
|
|
15
26
|
CveDb = dict[str, list[CveEntry]]
|
|
16
27
|
_NAME_MAP: dict[str, str] = {
|
|
17
28
|
"nginx": "nginx",
|
|
@@ -89,7 +100,10 @@ _JQUERY_RE = re.compile("jquery[-/]?v?(\\d+\\.\\d+(?:\\.\\d+)?)", re.IGNORECASE)
|
|
|
89
100
|
_GENERATOR_RE = re.compile(
|
|
90
101
|
"<meta[^>]+name=[\\\"']generator[\\\"'][^>]+content=[\\\"']([^\\\"']+)[\\\"']", re.IGNORECASE
|
|
91
102
|
)
|
|
92
|
-
_SSH_RE = re.compile("openssh[
|
|
103
|
+
_SSH_RE = re.compile(r"openssh[\s_/:-](\d+\.\d+(?:p\d+)?)", re.IGNORECASE)
|
|
104
|
+
_CORE_COMMIT_RE = re.compile(
|
|
105
|
+
r"([A-Za-z][A-Za-z0-9\s-]*?)\s+Core\s+\(([a-f0-9]{4,})\)", re.IGNORECASE
|
|
106
|
+
)
|
|
93
107
|
|
|
94
108
|
|
|
95
109
|
@lru_cache(maxsize=1)
|
|
@@ -109,6 +123,11 @@ def load_cve_db() -> CveDb:
|
|
|
109
123
|
return cast("CveDb", products)
|
|
110
124
|
|
|
111
125
|
|
|
126
|
+
def _distro_tag(text: str) -> str:
|
|
127
|
+
match = _BACKPORT_DISTRO_RE.search(text)
|
|
128
|
+
return match.group(0) if match else ""
|
|
129
|
+
|
|
130
|
+
|
|
112
131
|
def _parse_version(value: str) -> tuple[tuple[int, str], ...]:
|
|
113
132
|
parts: list[tuple[int, str]] = []
|
|
114
133
|
for chunk in re.split("[.\\-_]", value.strip()):
|
|
@@ -140,10 +159,12 @@ def _in_range(version: str, rng: dict[str, str]) -> bool:
|
|
|
140
159
|
return bool(rng)
|
|
141
160
|
|
|
142
161
|
|
|
143
|
-
def _tokens(value: str, source: str, location: str) -> list[Software]:
|
|
162
|
+
def _tokens(value: str, source: str, location: str, os: str = "") -> list[Software]:
|
|
144
163
|
found: list[Software] = []
|
|
145
164
|
for name, version in _TOKEN_RE.findall(value):
|
|
146
|
-
found.append(
|
|
165
|
+
found.append(
|
|
166
|
+
Software(name=name.lower(), version=version, source=source, location=location, os=os)
|
|
167
|
+
)
|
|
147
168
|
return found
|
|
148
169
|
|
|
149
170
|
|
|
@@ -159,8 +180,27 @@ def extract_software(headers: Headers, body: str, location: str = "") -> list[So
|
|
|
159
180
|
|
|
160
181
|
server = headers.get("server")
|
|
161
182
|
if server:
|
|
162
|
-
|
|
183
|
+
server_os = _distro_tag(server)
|
|
184
|
+
server_items = _tokens(server, "header:server", location, os=server_os)
|
|
185
|
+
for item in server_items:
|
|
163
186
|
add(item)
|
|
187
|
+
# If the Server banner names a known product without a version (e.g.
|
|
188
|
+
# "nginx" or "Apache"), still record it so it shows up in the software
|
|
189
|
+
# table even when no CVE can be matched yet.
|
|
190
|
+
if not server_items:
|
|
191
|
+
server_lower = server.lower()
|
|
192
|
+
for name in sorted(SERVER_NAMES, key=len, reverse=True):
|
|
193
|
+
if name in server_lower:
|
|
194
|
+
add(
|
|
195
|
+
Software(
|
|
196
|
+
name=name.lower().replace(" ", ""),
|
|
197
|
+
version=None,
|
|
198
|
+
source="header:server",
|
|
199
|
+
location=location,
|
|
200
|
+
os=server_os,
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
break
|
|
164
204
|
powered = headers.get("x-powered-by")
|
|
165
205
|
if powered:
|
|
166
206
|
for item in _tokens(powered, "header:x-powered-by", location):
|
|
@@ -181,6 +221,22 @@ def extract_software(headers: Headers, body: str, location: str = "") -> list[So
|
|
|
181
221
|
location=location,
|
|
182
222
|
)
|
|
183
223
|
)
|
|
224
|
+
core_match = _CORE_COMMIT_RE.search(body)
|
|
225
|
+
if core_match:
|
|
226
|
+
product = core_match.group(1).strip()
|
|
227
|
+
commit = core_match.group(2).lower()
|
|
228
|
+
if product:
|
|
229
|
+
add(
|
|
230
|
+
Software(
|
|
231
|
+
name=product.lower().replace(" ", ""),
|
|
232
|
+
version=commit,
|
|
233
|
+
source="body:core-commit",
|
|
234
|
+
location=location,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
for item in extract_generic_software(body, location=location):
|
|
239
|
+
add(item)
|
|
184
240
|
return software
|
|
185
241
|
|
|
186
242
|
|
|
@@ -193,11 +249,16 @@ def software_from_ports(scan: PortScan | None) -> list[Software]:
|
|
|
193
249
|
if not blob:
|
|
194
250
|
continue
|
|
195
251
|
location = f"{port.host}:{port.port}" if port.host else f":{port.port}"
|
|
252
|
+
port_os = port.os or _distro_tag(blob)
|
|
196
253
|
ssh = _SSH_RE.search(blob)
|
|
197
254
|
if ssh:
|
|
198
255
|
out.append(
|
|
199
256
|
Software(
|
|
200
|
-
name="openssh",
|
|
257
|
+
name="openssh",
|
|
258
|
+
version=ssh.group(1),
|
|
259
|
+
source="port-banner",
|
|
260
|
+
location=location,
|
|
261
|
+
os=port_os,
|
|
201
262
|
)
|
|
202
263
|
)
|
|
203
264
|
continue
|
|
@@ -208,6 +269,7 @@ def software_from_ports(scan: PortScan | None) -> list[Software]:
|
|
|
208
269
|
version=port.version,
|
|
209
270
|
source="port-banner",
|
|
210
271
|
location=location,
|
|
272
|
+
os=port_os,
|
|
211
273
|
)
|
|
212
274
|
)
|
|
213
275
|
return out
|
|
@@ -217,20 +279,24 @@ _AUTHORITATIVE = {"header:server", "header:x-powered-by", "port-banner"}
|
|
|
217
279
|
_SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
|
|
218
280
|
|
|
219
281
|
|
|
220
|
-
def _confidence(version: str, rng: dict[str, str], source: str) -> int:
|
|
221
|
-
|
|
282
|
+
def _confidence(version: str, rng: dict[str, str], source: str, backported: bool = False) -> int:
|
|
283
|
+
if backported:
|
|
284
|
+
# Distro backports are a common source of phantom CVEs because the
|
|
285
|
+
# banner version does not reveal the patchlevel. Keep them below the
|
|
286
|
+
# default confidence threshold so they only appear when explicitly asked.
|
|
287
|
+
if "start_incl" in rng or "start_excl" in rng:
|
|
288
|
+
return 45
|
|
289
|
+
return 25
|
|
222
290
|
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
291
|
bounded = ("start_incl" in rng or "start_excl" in rng) and (
|
|
228
292
|
"end_incl" in rng or "end_excl" in rng
|
|
229
293
|
)
|
|
230
|
-
|
|
231
|
-
if
|
|
232
|
-
|
|
233
|
-
|
|
294
|
+
authoritative = source in _AUTHORITATIVE
|
|
295
|
+
if authoritative and bounded and comps >= 3:
|
|
296
|
+
return 98
|
|
297
|
+
if (authoritative or bounded) and comps >= 2:
|
|
298
|
+
return 91
|
|
299
|
+
return 85
|
|
234
300
|
|
|
235
301
|
|
|
236
302
|
@dataclass
|
|
@@ -245,6 +311,8 @@ class _CveAgg:
|
|
|
245
311
|
url: str | None
|
|
246
312
|
locations: set[str] = field(default_factory=set[str])
|
|
247
313
|
sources: set[str] = field(default_factory=set[str])
|
|
314
|
+
unconfirmed: bool = False
|
|
315
|
+
caveat: str = ""
|
|
248
316
|
|
|
249
317
|
|
|
250
318
|
def _match_entries(
|
|
@@ -256,13 +324,17 @@ def _match_entries(
|
|
|
256
324
|
version = item.version
|
|
257
325
|
if not version:
|
|
258
326
|
return
|
|
327
|
+
if is_commit_hash(version):
|
|
328
|
+
return
|
|
329
|
+
backported = bool(item.os)
|
|
330
|
+
caveat = "distro backport likely — patchlevel not in banner" if backported else ""
|
|
259
331
|
for entry in entries:
|
|
260
332
|
ranges = cast("list[dict[str, str]]", entry.get("ranges") or [])
|
|
261
333
|
hit_rng = next((r for r in ranges if _in_range(version, r)), None)
|
|
262
334
|
if hit_rng is None:
|
|
263
335
|
continue
|
|
264
336
|
cve_id = str(entry["id"])
|
|
265
|
-
confidence = _confidence(version, hit_rng, item.source)
|
|
337
|
+
confidence = _confidence(version, hit_rng, item.source, backported=backported)
|
|
266
338
|
record = agg.get(cve_id)
|
|
267
339
|
if record is None:
|
|
268
340
|
record = _CveAgg(
|
|
@@ -274,13 +346,20 @@ def _match_entries(
|
|
|
274
346
|
confidence=confidence,
|
|
275
347
|
summary=str(entry.get("summary", "")),
|
|
276
348
|
url=entry.get("url"),
|
|
349
|
+
unconfirmed=backported,
|
|
350
|
+
caveat=caveat,
|
|
277
351
|
)
|
|
278
352
|
agg[cve_id] = record
|
|
279
353
|
if item.location:
|
|
280
354
|
record.locations.add(item.location)
|
|
281
355
|
if item.source:
|
|
282
356
|
record.sources.add(item.source)
|
|
283
|
-
if
|
|
357
|
+
if backported:
|
|
358
|
+
record.unconfirmed = True
|
|
359
|
+
record.caveat = caveat
|
|
360
|
+
if confidence < record.confidence:
|
|
361
|
+
record.confidence = confidence
|
|
362
|
+
elif confidence > record.confidence:
|
|
284
363
|
record.confidence = confidence
|
|
285
364
|
record.version = version
|
|
286
365
|
|
|
@@ -298,6 +377,8 @@ def _agg_to_matches(agg: dict[str, _CveAgg]) -> list[CveMatch]:
|
|
|
298
377
|
url=r.url,
|
|
299
378
|
locations=tuple(sorted(r.locations)),
|
|
300
379
|
sources=tuple(sorted(r.sources)),
|
|
380
|
+
unconfirmed=r.unconfirmed,
|
|
381
|
+
caveat=r.caveat,
|
|
301
382
|
)
|
|
302
383
|
for r in agg.values()
|
|
303
384
|
]
|
|
@@ -331,7 +412,7 @@ def merge_cve_matches(offline: list[CveMatch], online: list[CveMatch]) -> list[C
|
|
|
331
412
|
return _sort_matches(list(by_id.values()))
|
|
332
413
|
|
|
333
414
|
|
|
334
|
-
def match_cves(software: list[Software]) -> list[CveMatch]:
|
|
415
|
+
def match_cves(software: list[Software], *, min_confidence: int = 0) -> list[CveMatch]:
|
|
335
416
|
db = load_cve_db()
|
|
336
417
|
agg: dict[str, _CveAgg] = {}
|
|
337
418
|
for item in software:
|
|
@@ -341,7 +422,10 @@ def match_cves(software: list[Software]) -> list[CveMatch]:
|
|
|
341
422
|
entries = db.get(product_key)
|
|
342
423
|
if entries:
|
|
343
424
|
_match_entries(item, product_key, entries, agg)
|
|
344
|
-
|
|
425
|
+
matches = _agg_to_matches(agg)
|
|
426
|
+
if min_confidence > 0:
|
|
427
|
+
matches = [m for m in matches if m.confidence >= min_confidence]
|
|
428
|
+
return matches
|
|
345
429
|
|
|
346
430
|
|
|
347
431
|
_NVD_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
|
@@ -418,7 +502,7 @@ def _nvd_summary(descriptions: list[dict[str, str]]) -> str:
|
|
|
418
502
|
|
|
419
503
|
|
|
420
504
|
async def match_cves_online(
|
|
421
|
-
software: list[Software], *, timeout: float = 25.0, workers: int = 3
|
|
505
|
+
software: list[Software], *, timeout: float = 25.0, workers: int = 3, min_confidence: int = 0
|
|
422
506
|
) -> list[CveMatch]:
|
|
423
507
|
import aiohttp
|
|
424
508
|
|
|
@@ -455,4 +539,7 @@ async def match_cves_online(
|
|
|
455
539
|
for (product_key, item), entries in zip(products.items(), gathered, strict=True):
|
|
456
540
|
if entries:
|
|
457
541
|
_match_entries(item, product_key, entries, agg)
|
|
458
|
-
|
|
542
|
+
matches = _agg_to_matches(agg)
|
|
543
|
+
if min_confidence > 0:
|
|
544
|
+
matches = [m for m in matches if m.confidence >= min_confidence]
|
|
545
|
+
return matches
|