stackscan 2.2.0__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.
Files changed (68) hide show
  1. {stackscan-2.2.0 → stackscan-2.2.2}/.gitignore +4 -1
  2. {stackscan-2.2.0 → stackscan-2.2.2}/PKG-INFO +5 -3
  3. {stackscan-2.2.0 → stackscan-2.2.2}/README.md +4 -2
  4. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/__init__.py +1 -1
  5. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/analyzers/__init__.py +7 -2
  6. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/analyzers/creds.py +107 -35
  7. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/analyzers/cve.py +47 -15
  8. stackscan-2.2.2/src/stackscan/analyzers/osdetect.py +127 -0
  9. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/analyzers/services.py +33 -20
  10. stackscan-2.2.2/src/stackscan/analyzers/social.py +76 -0
  11. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/analyzers/tech.py +120 -3
  12. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/cli.py +117 -11
  13. stackscan-2.2.2/src/stackscan/data/builtin.sigdb +0 -0
  14. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/export.py +34 -1
  15. stackscan-2.2.2/src/stackscan/net/fingerprint.py +114 -0
  16. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/net/ports.py +47 -13
  17. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/net/tls.py +20 -9
  18. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/render.py +53 -5
  19. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/scan.py +37 -22
  20. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/types/__init__.py +6 -0
  21. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/types/output.py +67 -0
  22. stackscan-2.2.2/tests/test_brute.py +86 -0
  23. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_cve.py +48 -1
  24. stackscan-2.2.2/tests/test_osdetect.py +54 -0
  25. stackscan-2.2.2/tests/test_ports.py +97 -0
  26. stackscan-2.2.2/tests/test_scan.py +54 -0
  27. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_services.py +13 -2
  28. stackscan-2.2.2/tests/test_social.py +30 -0
  29. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_tech.py +24 -0
  30. stackscan-2.2.0/src/stackscan/data/builtin.sigdb +0 -0
  31. stackscan-2.2.0/src/stackscan/net/fingerprint.py +0 -41
  32. stackscan-2.2.0/tests/test_ports.py +0 -41
  33. {stackscan-2.2.0 → stackscan-2.2.2}/LICENSE +0 -0
  34. {stackscan-2.2.0 → stackscan-2.2.2}/pyproject.toml +0 -0
  35. {stackscan-2.2.0 → stackscan-2.2.2}/scripts/build_cve_db.py +0 -0
  36. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/__main__.py +0 -0
  37. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/analyzers/exposure.py +0 -0
  38. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/analyzers/infra.py +0 -0
  39. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/analyzers/security.py +0 -0
  40. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/config/__init__.py +0 -0
  41. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/config/sigdb_loader.py +0 -0
  42. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/config/sources.py +0 -0
  43. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/core/__init__.py +0 -0
  44. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/core/core.py +0 -0
  45. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/data/cve.json.gz +0 -0
  46. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/data/reekeer-logo.png +0 -0
  47. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/data/subdomains.txt +0 -0
  48. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/net/__init__.py +0 -0
  49. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/net/dns.py +0 -0
  50. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/net/geo.py +0 -0
  51. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/net/ipinfo.py +0 -0
  52. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/net/resolver.py +0 -0
  53. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/net/subdomains.py +0 -0
  54. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/net/tld.py +0 -0
  55. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/theme.py +0 -0
  56. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/types/models.py +0 -0
  57. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/utils/__init__.py +0 -0
  58. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/utils/paths.py +0 -0
  59. {stackscan-2.2.0 → stackscan-2.2.2}/src/stackscan/utils/urls.py +0 -0
  60. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_builtin_sigdb.py +0 -0
  61. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_cli_helpers.py +0 -0
  62. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_creds.py +0 -0
  63. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_dns_records.py +0 -0
  64. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_export.py +0 -0
  65. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_extra.py +0 -0
  66. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_infra.py +0 -0
  67. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_sources.py +0 -0
  68. {stackscan-2.2.0 → stackscan-2.2.2}/tests/test_subdomains.py +0 -0
@@ -132,4 +132,7 @@ frameworks.schema.json
132
132
  .Spotlight-V100
133
133
  .Trashes
134
134
  ehthumbs.db
135
- Thumbs.db
135
+ Thumbs.db
136
+
137
+ # stackscan genereted reports
138
+ stackscan-report.html
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackscan
3
- Version: 2.2.0
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
@@ -68,7 +68,7 @@ solicit and are opt-in. **Only scan systems you are authorized to test.**
68
68
  ## 📦 Installation
69
69
 
70
70
  ```bash
71
- pip install "stackscan @ git+https://github.com/reekeer/stackscan.git"
71
+ pip install "stackscan"
72
72
 
73
73
  # optional extra
74
74
  pip install "stackscan[geo]" # offline IP geolocation via geoip2 + a MaxMind .mmdb
@@ -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. |
@@ -30,7 +30,7 @@ solicit and are opt-in. **Only scan systems you are authorized to test.**
30
30
  ## 📦 Installation
31
31
 
32
32
  ```bash
33
- pip install "stackscan @ git+https://github.com/reekeer/stackscan.git"
33
+ pip install "stackscan"
34
34
 
35
35
  # optional extra
36
36
  pip install "stackscan[geo]" # offline IP geolocation via geoip2 + a MaxMind .mmdb
@@ -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. |
@@ -2,4 +2,4 @@ from .core import StackscanSession
2
2
  from .utils import normalize_url
3
3
 
4
4
  __all__ = ["StackscanSession", "__version__", "normalize_url"]
5
- __version__ = "2.2.0"
5
+ __version__ = "2.2.2"
@@ -1,4 +1,4 @@
1
- from stackscan.analyzers.creds import check_default_creds
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
- "check_default_creds",
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 "http" in service or port.port in (80, 8080, 8000, 8081, 8888, 9000, 631, 7547):
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
- async def check_default_creds(
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
- cred_limit: int = 100,
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 check(port: int, tls: bool) -> None:
178
+ async def probe(port: int, tls: bool) -> None:
148
179
  async with semaphore:
149
- finding = await _check_endpoint(session, host, port, tls, creds)
150
- if finding is not None:
151
- findings.append(finding)
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(*(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
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 _check_endpoint(
192
+ async def _probe_endpoint(
159
193
  session: aiohttp.ClientSession,
160
194
  host: str,
161
195
  port: int,
162
196
  tls: bool,
163
- creds: tuple[tuple[str, str], ...],
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
- 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,
220
+ return BruteTarget(
221
+ host=host,
222
+ port=port,
223
+ tls=tls,
200
224
  service=f"{scheme} ({server or realm or 'device'})",
201
- kind="auth-required",
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(Software(name=name.lower(), version=version, source=source, location=location))
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
- for item in _tokens(server, "header:server", location):
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", version=ssh.group(1), source="port-banner", location=location
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
- score += 12 if bounded else 6
231
- if source in _AUTHORITATIVE:
232
- score += 12
233
- return max(30, min(98, score))
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 confidence > record.confidence:
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