stackscan 2.7.1__tar.gz → 2.7.3__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 (84) hide show
  1. {stackscan-2.7.1 → stackscan-2.7.3}/PKG-INFO +2 -3
  2. {stackscan-2.7.1 → stackscan-2.7.3}/pyproject.toml +1 -17
  3. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/__init__.py +1 -1
  4. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/cve.py +26 -3
  5. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/exposure.py +26 -6
  6. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/generic.py +18 -9
  7. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/cli.py +10 -9
  8. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/config/sources.py +12 -2
  9. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/core/core.py +20 -3
  10. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/export.py +20 -6
  11. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/net/fingerprint.py +12 -10
  12. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/net/ports.py +11 -8
  13. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/render.py +42 -33
  14. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/runner.py +15 -4
  15. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/scan.py +14 -5
  16. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/scanners/secrets.py +24 -1
  17. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/utils/__init__.py +2 -1
  18. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/utils/urls.py +15 -0
  19. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_cli_helpers.py +17 -1
  20. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_cve.py +59 -0
  21. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_export.py +35 -1
  22. stackscan-2.7.3/tests/test_exposure.py +57 -0
  23. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_extra.py +8 -0
  24. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_ports.py +86 -0
  25. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_render.py +25 -2
  26. stackscan-2.7.3/tests/test_runner.py +134 -0
  27. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_scan.py +21 -1
  28. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_scanners.py +12 -3
  29. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_sources.py +20 -1
  30. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_tech.py +7 -0
  31. {stackscan-2.7.1 → stackscan-2.7.3}/.gitignore +0 -0
  32. {stackscan-2.7.1 → stackscan-2.7.3}/LICENSE +0 -0
  33. {stackscan-2.7.1 → stackscan-2.7.3}/README.md +0 -0
  34. {stackscan-2.7.1 → stackscan-2.7.3}/scripts/build_cve_db.py +0 -0
  35. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/__main__.py +0 -0
  36. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/__init__.py +0 -0
  37. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/creds.py +0 -0
  38. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/infra.py +0 -0
  39. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/osdetect.py +0 -0
  40. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/security.py +0 -0
  41. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/services.py +0 -0
  42. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/social.py +0 -0
  43. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/tech.py +0 -0
  44. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/analyzers/vibe.py +0 -0
  45. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/config/__init__.py +0 -0
  46. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/config/sigdb_loader.py +0 -0
  47. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/core/__init__.py +0 -0
  48. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/data/builtin.sigdb +0 -0
  49. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/data/cve.json.gz +0 -0
  50. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/data/reekeer-logo.png +0 -0
  51. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/data/subdomains.txt +0 -0
  52. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/embed.py +0 -0
  53. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/net/__init__.py +0 -0
  54. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/net/dns.py +0 -0
  55. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/net/geo.py +0 -0
  56. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/net/ipinfo.py +0 -0
  57. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/net/resolver.py +0 -0
  58. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/net/subdomains.py +0 -0
  59. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/net/tld.py +0 -0
  60. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/net/tls.py +0 -0
  61. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/net/whois.py +0 -0
  62. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/scanners/__init__.py +0 -0
  63. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/scanners/isp_blocked.py +0 -0
  64. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/scanners/takeover.py +0 -0
  65. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/theme.py +0 -0
  66. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/types/__init__.py +0 -0
  67. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/types/models.py +0 -0
  68. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/types/output.py +0 -0
  69. {stackscan-2.7.1 → stackscan-2.7.3}/src/stackscan/utils/paths.py +0 -0
  70. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_brute.py +0 -0
  71. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_builtin_sigdb.py +0 -0
  72. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_creds.py +0 -0
  73. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_dns_records.py +0 -0
  74. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_embed.py +0 -0
  75. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_infra.py +0 -0
  76. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_osdetect.py +0 -0
  77. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_reekeer_host.py +0 -0
  78. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_services.py +0 -0
  79. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_social.py +0 -0
  80. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_subdomains.py +0 -0
  81. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_theme.py +0 -0
  82. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_vhost.py +0 -0
  83. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_vibe.py +0 -0
  84. {stackscan-2.7.1 → stackscan-2.7.3}/tests/test_whois.py +0 -0
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: stackscan
3
- Version: 2.7.1
3
+ Version: 2.7.3
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
7
7
  Author: reekeer team
8
- Maintainer-email: delewer <del@reekeer.hidden>
8
+ Maintainer: delewer
9
9
  License: MIT
10
10
  License-File: LICENSE
11
11
  Keywords: cli,fingerprinting,osint,stack,tech,wappalyzer
@@ -26,7 +26,6 @@ Requires-Dist: sigdb>=1.0.2
26
26
  Provides-Extra: dev
27
27
  Requires-Dist: black>=24.0; extra == 'dev'
28
28
  Requires-Dist: build>=1.0; extra == 'dev'
29
- Requires-Dist: cibuildwheel>=2.16; extra == 'dev'
30
29
  Requires-Dist: geoip2>=4.8; extra == 'dev'
31
30
  Requires-Dist: pyright>=1.1; extra == 'dev'
32
31
  Requires-Dist: pytest>=8.0; extra == 'dev'
@@ -14,7 +14,7 @@ authors = [
14
14
  { name = "reekeer team" }
15
15
  ]
16
16
  maintainers = [
17
- { name = "delewer", email = "del@reekeer.hidden" }
17
+ { name = "delewer" }
18
18
  ]
19
19
 
20
20
  dependencies = [
@@ -56,7 +56,6 @@ dev = [
56
56
  "pytest>=8.0",
57
57
  "twine>=5.0",
58
58
  "build>=1.0",
59
- "cibuildwheel>=2.16",
60
59
  "geoip2>=4.8"
61
60
  ]
62
61
 
@@ -171,18 +170,3 @@ reportMissingTypeStubs = false
171
170
  reportOptionalMemberAccess = true
172
171
  reportOptionalSubscript = true
173
172
  reportGeneralTypeIssues = true
174
-
175
- [tool.cibuildwheel]
176
- build = "cp311-* cp312-* cp313-*"
177
- skip = "*-musllinux* *-win32"
178
- test-command = "pytest {project}/tests"
179
- test-extras = ["dev"]
180
-
181
- [tool.cibuildwheel.linux]
182
- archs = ["x86_64", "aarch64"]
183
-
184
- [tool.cibuildwheel.macos]
185
- archs = ["x86_64", "arm64"]
186
-
187
- [tool.cibuildwheel.windows]
188
- archs = ["AMD64"]
@@ -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.7.1"
5
+ __version__ = "2.7.3"
@@ -101,6 +101,16 @@ _GENERATOR_RE = re.compile(
101
101
  "<meta[^>]+name=[\\\"']generator[\\\"'][^>]+content=[\\\"']([^\\\"']+)[\\\"']", re.IGNORECASE
102
102
  )
103
103
  _SSH_RE = re.compile(r"openssh[\s_/:-](\d+\.\d+(?:p\d+)?)", re.IGNORECASE)
104
+ _VERSION_PREFIX_RE = re.compile(r"\s*[vV]?\d+\.\d")
105
+ _IMPRECISE_VERSION_RE = re.compile(
106
+ r"\b(?:or|and) (?:later|higher|earlier|newer|above|below|greater)\b", re.IGNORECASE
107
+ )
108
+
109
+
110
+ def _usable_port_version(version: str) -> bool:
111
+ if not _VERSION_PREFIX_RE.match(version):
112
+ return False
113
+ return not _IMPRECISE_VERSION_RE.search(version)
104
114
  _CORE_COMMIT_RE = re.compile(
105
115
  r"([A-Za-z][A-Za-z0-9\s-]*?)\s+Core\s+\(([a-f0-9]{4,})\)", re.IGNORECASE
106
116
  )
@@ -276,7 +286,7 @@ def software_from_ports(scan: PortScan | None) -> list[Software]:
276
286
  )
277
287
  )
278
288
  continue
279
- if port.product and port.version:
289
+ if port.product and port.version and _usable_port_version(port.version):
280
290
  out.append(
281
291
  Software(
282
292
  name=port.product.lower().split()[0],
@@ -294,6 +304,11 @@ _SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
294
304
 
295
305
 
296
306
  def _confidence(version: str, rng: dict[str, str], source: str, backported: bool = False) -> int:
307
+ if source.startswith("body-text:"):
308
+ # a bare "name version" scraped from page prose (e.g. a blog post),
309
+ # not a live banner — kept below the default threshold so it is hidden
310
+ # unless the user asks for low-confidence matches.
311
+ return 30
297
312
  if backported:
298
313
  if "start_incl" in rng or "start_excl" in rng:
299
314
  return 35
@@ -337,8 +352,16 @@ def _match_entries(
337
352
  return
338
353
  if is_commit_hash(version):
339
354
  return
355
+ if len([c for c in re.split("[.\\-_]", version) if c[:1].isdigit()]) < 2:
356
+ return
357
+ from_prose = item.source.startswith("body-text:")
340
358
  backported = bool(item.os)
341
- caveat = "distro backport likely — patchlevel not in banner" if backported else ""
359
+ if from_prose:
360
+ caveat = "version mentioned in page text — may not be the running software"
361
+ elif backported:
362
+ caveat = "distro backport likely — patchlevel not in banner"
363
+ else:
364
+ caveat = ""
342
365
  for entry in entries:
343
366
  ranges = cast("list[dict[str, str]]", entry.get("ranges") or [])
344
367
  hit_rng = next((r for r in ranges if _in_range(version, r)), None)
@@ -357,7 +380,7 @@ def _match_entries(
357
380
  confidence=confidence,
358
381
  summary=str(entry.get("summary", "")),
359
382
  url=entry.get("url"),
360
- unconfirmed=backported,
383
+ unconfirmed=backported or from_prose,
361
384
  caveat=caveat,
362
385
  )
363
386
  agg[cve_id] = record
@@ -18,6 +18,26 @@ class ExposureProbe:
18
18
  insecure: bool
19
19
 
20
20
 
21
+ def _looks_like_robots(body: str) -> bool:
22
+ lowered = body.lower()
23
+ return any(
24
+ directive in lowered
25
+ for directive in ("user-agent:", "disallow:", "allow:", "sitemap:", "crawl-delay:")
26
+ )
27
+
28
+
29
+ def _looks_like_sitemap(body: str) -> bool:
30
+ lowered = body.lstrip().lower()
31
+ return lowered.startswith("<?xml") or "<urlset" in lowered or "<sitemapindex" in lowered
32
+
33
+
34
+ def _looks_like_security_txt(body: str) -> bool:
35
+ lowered = body.lower()
36
+ return any(
37
+ field in lowered for field in ("contact:", "expires:", "encryption:", "acknowledgments:")
38
+ )
39
+
40
+
21
41
  async def _get(session: StackscanSession, url: str, probe: ExposureProbe) -> tuple[int | None, str]:
22
42
  try:
23
43
  result = await session.fetch(
@@ -39,20 +59,20 @@ async def analyze_exposure(
39
59
  urls: dict[str, str] = {}
40
60
 
41
61
  robots_url = urljoin(base_url, "/robots.txt")
42
- robots_status, _ = await _get(session, robots_url, probe)
43
- robots = robots_status == 200
62
+ robots_status, robots_body = await _get(session, robots_url, probe)
63
+ robots = robots_status == 200 and _looks_like_robots(robots_body)
44
64
  if robots:
45
65
  urls["robots.txt"] = robots_url
46
66
 
47
67
  sitemap_url = urljoin(base_url, "/sitemap.xml")
48
- sitemap_status, _ = await _get(session, sitemap_url, probe)
49
- sitemap = sitemap_status == 200
68
+ sitemap_status, sitemap_body = await _get(session, sitemap_url, probe)
69
+ sitemap = sitemap_status == 200 and _looks_like_sitemap(sitemap_body)
50
70
  if sitemap:
51
71
  urls["sitemap.xml"] = sitemap_url
52
72
 
53
73
  sec_url = urljoin(base_url, "/.well-known/security.txt")
54
- sec_status, _ = await _get(session, sec_url, probe)
55
- security_txt = sec_status == 200
74
+ sec_status, sec_body = await _get(session, sec_url, probe)
75
+ security_txt = sec_status == 200 and _looks_like_security_txt(sec_body)
56
76
  if security_txt:
57
77
  urls["security.txt"] = sec_url
58
78
 
@@ -93,7 +93,7 @@ _POWERED_BY_PLAIN_RE = re.compile(
93
93
  _SERVER_VERSION_RE = re.compile(
94
94
  r"\b("
95
95
  + "|".join(re.escape(name) for name in sorted(SERVER_NAMES, key=len, reverse=True))
96
- + r")[/ ]v?(\d+\.\d+(?:\.\d+){0,2})",
96
+ + r")([/ ])v?(\d+\.\d+(?:\.\d+){0,2})",
97
97
  re.IGNORECASE,
98
98
  )
99
99
 
@@ -155,6 +155,11 @@ def _software_name(name: str) -> str:
155
155
  return _normalize_name(name).lower().replace(" ", "")
156
156
 
157
157
 
158
+ def _name_detached_from_version(name: str) -> bool:
159
+ """True when the version sits behind a stopword/other product, not this name."""
160
+ return _software_name(_clean_product_name(name)) != _software_name(name)
161
+
162
+
158
163
  def _category(name: str) -> str:
159
164
  if _software_name(name) in {n.replace(" ", "") for n in SERVER_NAMES}:
160
165
  return "infrastructure"
@@ -195,16 +200,18 @@ def extract_generic_tech(body: str) -> list[Technology]:
195
200
 
196
201
  for match in _SERVER_VERSION_RE.finditer(body):
197
202
  name = _normalize_name(match.group(1))
198
- version = match.group(2)
199
- remember(name, f"body:{name}/{version}", version)
203
+ version = match.group(3)
204
+ source = f"body:{name}/{version}" if match.group(2) == "/" else f"body-text:{name} {version}"
205
+ remember(name, source, version)
200
206
 
201
207
  powered_starts: set[int] = set()
202
208
  for match in _POWERED_BY_VERSION_RE.finditer(body):
203
209
  name = _normalize_name(match.group(1))
204
- version = match.group(2)
210
+ version = match.group(2) if not _name_detached_from_version(name) else None
205
211
  powered_starts.add(match.start())
206
212
  if name and not _is_noise(name):
207
- remember(name, f"body:powered-by {name} {version}", version)
213
+ evidence = f"body:powered-by {name} {version}" if version else f"body:powered-by {name}"
214
+ remember(name, evidence, version)
208
215
 
209
216
  for match in _POWERED_BY_PLAIN_RE.finditer(body):
210
217
  if match.start() in powered_starts:
@@ -275,16 +282,18 @@ def extract_generic_software(body: str, location: str = "") -> list[Software]:
275
282
 
276
283
  for match in _SERVER_VERSION_RE.finditer(body):
277
284
  name = _normalize_name(match.group(1))
278
- version = match.group(2)
279
- add(name, version, f"body:{name}/{version}")
285
+ version = match.group(3)
286
+ source = f"body:{name}/{version}" if match.group(2) == "/" else f"body-text:{name} {version}"
287
+ add(name, version, source)
280
288
 
281
289
  powered_starts: set[int] = set()
282
290
  for match in _POWERED_BY_VERSION_RE.finditer(body):
283
291
  name = _normalize_name(match.group(1))
284
- version = match.group(2)
292
+ version = match.group(2) if not _name_detached_from_version(name) else None
285
293
  powered_starts.add(match.start())
286
294
  if name:
287
- add(name, version, f"body:powered-by {name} {version}")
295
+ evidence = f"body:powered-by {name} {version}" if version else f"body:powered-by {name}"
296
+ add(name, version, evidence)
288
297
 
289
298
  for match in _POWERED_BY_PLAIN_RE.finditer(body):
290
299
  if match.start() in powered_starts:
@@ -10,9 +10,10 @@ from collections.abc import Iterable
10
10
  from datetime import UTC, datetime
11
11
  from functools import lru_cache
12
12
  from pathlib import Path
13
- from typing import Any
13
+ from typing import Any, cast
14
14
 
15
15
  from rich.console import Console
16
+ from rich.markup import escape
16
17
  from rich.progress import Progress
17
18
  from rich.table import Table
18
19
 
@@ -362,7 +363,7 @@ def _bar(label: str, total: int | None = None) -> Any:
362
363
  if _reekeer_progress is None:
363
364
  return None
364
365
  try:
365
- return _reekeer_progress.bar(label, total=total)
366
+ return cast("Any", _reekeer_progress).bar(label, total=total)
366
367
  except Exception:
367
368
  # Reporting progress is not what stackscan is for. It must never be the reason a scan fails.
368
369
  return None
@@ -710,13 +711,13 @@ def _render_table(reports: list[ScanReport], show_empty: bool) -> None:
710
711
  ips.extend(report.network.ipv4)
711
712
  ips.extend(report.network.ipv6)
712
713
  table.add_row(
713
- report.final_url or report.url,
714
- ", ".join(ips) if ips else "-",
714
+ escape(report.final_url or report.url),
715
+ escape(", ".join(ips)) if ips else "-",
715
716
  str(report.status) if report.status is not None else "-",
716
- _infra_summary(report),
717
- _format_detected(report.by_category()),
717
+ escape(_infra_summary(report)),
718
+ escape(_format_detected(report.by_category())),
718
719
  _exposure_summary(report),
719
- report.error or "",
720
+ escape(report.error or ""),
720
721
  )
721
722
  console.print(table)
722
723
 
@@ -817,11 +818,11 @@ def _scan_summary(reports: list[ScanReport], elapsed: float) -> str:
817
818
 
818
819
 
819
820
  def _warn(console: Console, message: str) -> None:
820
- console.print(f"[{theme.WARN}]{_glyphs().warn}[/] {message}", highlight=False)
821
+ console.print(f"[{theme.WARN}]{_glyphs().warn}[/] {escape(message)}", highlight=False)
821
822
 
822
823
 
823
824
  def _error(console: Console, message: str) -> None:
824
- console.print(f"[{theme.DANGER}]{_glyphs().err}[/] {message}", highlight=False)
825
+ console.print(f"[{theme.DANGER}]{_glyphs().err}[/] {escape(message)}", highlight=False)
825
826
 
826
827
 
827
828
  def _set_title(title: str) -> None:
@@ -83,8 +83,18 @@ def _looks_like_git(url: str) -> bool:
83
83
  return url.endswith(".git") or url.startswith(("git@", "git+", "ssh://"))
84
84
 
85
85
 
86
+ _GIT_SCHEMES = ("https://", "http://", "ssh://", "git://", "file://")
87
+
88
+
86
89
  def _normalize_git_url(url: str) -> str:
87
- return url[4:] if url.startswith("git+") else url
90
+ normalized = url[4:] if url.startswith("git+") else url
91
+ if normalized.startswith("git@") or normalized.startswith(_GIT_SCHEMES):
92
+ return normalized
93
+ if "://" not in normalized and "::" not in normalized:
94
+ expanded = Path(normalized).expanduser()
95
+ if expanded.exists():
96
+ return str(expanded)
97
+ raise SourceError(f"unsupported git url: {url}")
88
98
 
89
99
 
90
100
  def _detect_kind(url: str) -> str:
@@ -385,7 +395,7 @@ def _materialize_git(url: str, dest: Path) -> _Materialized:
385
395
  clone_url = _normalize_git_url(url)
386
396
  try:
387
397
  subprocess.run(
388
- ["git", "clone", "--depth", "1", clone_url, str(checkout)],
398
+ ["git", "clone", "--depth", "1", "--", clone_url, str(checkout)],
389
399
  check=True,
390
400
  capture_output=True,
391
401
  text=True,
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import codecs
3
4
  from collections.abc import Iterable
4
5
 
5
6
  from aiohttp import ClientSession, ClientTimeout
@@ -7,11 +8,23 @@ from aiohttp import ClientSession, ClientTimeout
7
8
  from stackscan.net.resolver import build_connector
8
9
  from stackscan.types import FetchResult
9
10
 
11
+ _MAX_DRAIN_BYTES = 4 * 1024 * 1024
12
+
10
13
 
11
14
  def _lower_headers(items: Iterable[tuple[str, str]]) -> dict[str, str]:
12
15
  return {key.lower(): value for key, value in items}
13
16
 
14
17
 
18
+ def _charset(declared: str | None) -> str:
19
+ if not declared:
20
+ return "utf-8"
21
+ try:
22
+ codecs.lookup(declared)
23
+ except LookupError:
24
+ return "utf-8"
25
+ return declared
26
+
27
+
15
28
  class StackscanSession:
16
29
  def __init__(self) -> None:
17
30
  self._session: ClientSession | None = None
@@ -54,10 +67,14 @@ class StackscanSession:
54
67
  headers = _lower_headers(header_items)
55
68
  raw_headers = [f"{key.lower()}: {value}" for key, value in header_items]
56
69
  cookies = resp.headers.getall("Set-Cookie", [])
57
- charset = resp.charset or "utf-8"
70
+ charset = _charset(resp.charset)
58
71
  body_bytes = await resp.content.read(max_bytes)
59
- while await resp.content.read(8192):
60
- pass
72
+ drained = 0
73
+ while drained < _MAX_DRAIN_BYTES:
74
+ chunk = await resp.content.read(8192)
75
+ if not chunk:
76
+ break
77
+ drained += len(chunk)
61
78
  body = body_bytes.decode(charset, errors="replace")
62
79
  url_final = str(resp.url)
63
80
  version = resp.version
@@ -27,6 +27,15 @@ def to_json(payload: Payload) -> str:
27
27
  return json.dumps(payload, ensure_ascii=False, indent=2)
28
28
 
29
29
 
30
+ def _script_json(value: Any) -> str:
31
+ return (
32
+ json.dumps(value, ensure_ascii=False)
33
+ .replace("<", "\\u003c")
34
+ .replace(">", "\\u003e")
35
+ .replace("&", "\\u0026")
36
+ )
37
+
38
+
30
39
  def build_graph(reports: list[dict[str, Any]]) -> dict[str, Any]:
31
40
  nodes: dict[str, dict[str, Any]] = {}
32
41
  edges: set[tuple[str, str, str]] = set()
@@ -407,7 +416,7 @@ def _html_graph(reports: list[dict[str, Any]]) -> str:
407
416
  if len(present_types) < 2:
408
417
  return ""
409
418
 
410
- graph_json = json.dumps(
419
+ graph_json = _script_json(
411
420
  {
412
421
  "nodes": [
413
422
  {
@@ -420,8 +429,7 @@ def _html_graph(reports: list[dict[str, Any]]) -> str:
420
429
  for n in graph["nodes"]
421
430
  ],
422
431
  "edges": graph["edges"],
423
- },
424
- ensure_ascii=False,
432
+ }
425
433
  )
426
434
 
427
435
  legend_items = "".join(
@@ -438,7 +446,7 @@ def _html_graph(reports: list[dict[str, Any]]) -> str:
438
446
  </div>
439
447
  <script>
440
448
  (function(){{
441
- const colors = {json.dumps(type_color)};
449
+ const colors = {_script_json(type_color)};
442
450
  const data = {graph_json};
443
451
  const svg = document.getElementById('netgraph');
444
452
  const viewport = svg.querySelector('.graph-viewport');
@@ -493,8 +501,14 @@ def _html_graph(reports: list[dict[str, Any]]) -> str:
493
501
  }}
494
502
 
495
503
  function showTip(ev, n) {{
496
- const meta = Object.entries(n.meta || {{}}).map(([k,v]) => `${{k}}: ${{v}}`).join('<br>');
497
- tooltip.innerHTML = `<strong>${{n.label}}</strong> (${{n.type}})${{meta ? '<br>' + meta : ''}}`;
504
+ const strong = document.createElement('strong');
505
+ strong.textContent = n.label;
506
+ const parts = [strong, document.createTextNode(` (${{n.type}})`)];
507
+ Object.entries(n.meta || {{}}).forEach(([k, v]) => {{
508
+ parts.push(document.createElement('br'));
509
+ parts.push(document.createTextNode(`${{k}}: ${{v}}`));
510
+ }});
511
+ tooltip.replaceChildren(...parts);
498
512
  tooltip.style.opacity = 1;
499
513
  moveTip(ev);
500
514
  }}
@@ -92,6 +92,16 @@ def fingerprint_http(raw_response: str) -> tuple[str | None, str | None]:
92
92
  return (server, None)
93
93
 
94
94
 
95
+ def normalize_mysql_version(product: str | None, version: str) -> tuple[str, str, str]:
96
+ distro = extract_distro(version) or ""
97
+ if version.startswith("5.5.5-"):
98
+ version = version[6:]
99
+ product = "MariaDB"
100
+ elif "mariadb" in version.lower():
101
+ product = "MariaDB"
102
+ return (product or "MySQL", version.split("-", 1)[0] or version, distro)
103
+
104
+
95
105
  def fingerprint_mysql(data: bytes) -> tuple[str | None, str | None, str | None, bool]:
96
106
  if not data or len(data) < 5:
97
107
  return (None, None, None, False)
@@ -108,13 +118,5 @@ def fingerprint_mysql(data: bytes) -> tuple[str | None, str | None, str | None,
108
118
  version = version.replace("\x00", "").strip()
109
119
  if not version:
110
120
  return (None, None, None, False)
111
- if version.startswith("5.5.5-"):
112
- version = version[6:]
113
- product = "MariaDB"
114
- elif "mariadb" in version.lower():
115
- product = "MariaDB"
116
- else:
117
- product = "MySQL"
118
- clean_version = version.split("-", 1)[0]
119
- distro = extract_distro(version)
120
- return (product, clean_version, distro, False)
121
+ product, clean_version, distro = normalize_mysql_version(None, version)
122
+ return (product, clean_version, distro or None, False)
@@ -8,6 +8,7 @@ from stackscan.net.fingerprint import (
8
8
  fingerprint_banner,
9
9
  fingerprint_http,
10
10
  fingerprint_mysql,
11
+ normalize_mysql_version,
11
12
  sanitize_banner,
12
13
  )
13
14
  from stackscan.types import Port, PortScan
@@ -108,9 +109,9 @@ def _run_nmap(host: str, ports: tuple[int, ...]) -> PortScan | None:
108
109
  continue
109
110
  product = info.get("product") or None
110
111
  version = info.get("version") or None
111
- extra = info.get("extrainfo") or None
112
- if extra and version:
113
- version = f"{version} ({extra})"
112
+ os_tag = ""
113
+ if version and (product or "").lower() in ("mysql", "mariadb"):
114
+ product, version, os_tag = normalize_mysql_version(product, version)
114
115
  found.append(
115
116
  Port(
116
117
  port=int(number),
@@ -120,6 +121,7 @@ def _run_nmap(host: str, ports: tuple[int, ...]) -> PortScan | None:
120
121
  product=product,
121
122
  version=version,
122
123
  host=host,
124
+ os=os_tag,
123
125
  )
124
126
  )
125
127
  found.sort(key=lambda p: p.port)
@@ -227,9 +229,10 @@ async def _http_probe(
227
229
  await asyncio.wait_for(writer.drain(), timeout=min(timeout, 2.5))
228
230
  except (TimeoutError, OSError):
229
231
  return (None, None, "")
230
- raw = await _read(reader, timeout)
231
- if not raw:
232
+ data = await _read_bytes(reader, timeout)
233
+ if not data:
232
234
  return (None, None, "")
235
+ raw = data.decode("utf-8", "replace")
233
236
  product, version = fingerprint_http(raw)
234
237
  os = _os_from_banner(raw)
235
238
  return (product, version, os)
@@ -244,10 +247,10 @@ async def _rtsp_probe(
244
247
  await asyncio.wait_for(writer.drain(), timeout=min(timeout, 2.5))
245
248
  except (TimeoutError, OSError):
246
249
  return (None, None)
247
- raw = await _read(reader, timeout)
248
- if not raw:
250
+ data = await _read_bytes(reader, timeout)
251
+ if not data:
249
252
  return (None, None)
250
- server = fingerprint_http(raw)
253
+ server = fingerprint_http(data.decode("utf-8", "replace"))
251
254
  if server != (None, None):
252
255
  return server
253
256
  return ("RTSP", None)