stackscan 2.7.2__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.
- {stackscan-2.7.2 → stackscan-2.7.3}/PKG-INFO +1 -2
- {stackscan-2.7.2 → stackscan-2.7.3}/pyproject.toml +0 -16
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/__init__.py +1 -1
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/cve.py +26 -3
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/exposure.py +26 -6
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/generic.py +18 -9
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/cli.py +10 -9
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/config/sources.py +12 -2
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/core/core.py +20 -3
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/export.py +20 -6
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/net/fingerprint.py +12 -10
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/net/ports.py +11 -8
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/render.py +42 -33
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/runner.py +15 -4
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/scan.py +14 -5
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/scanners/secrets.py +24 -1
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/utils/__init__.py +2 -1
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/utils/urls.py +15 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_cli_helpers.py +17 -1
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_cve.py +59 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_export.py +35 -1
- stackscan-2.7.3/tests/test_exposure.py +57 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_extra.py +8 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_ports.py +86 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_render.py +25 -2
- stackscan-2.7.3/tests/test_runner.py +134 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_scan.py +21 -1
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_scanners.py +12 -3
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_sources.py +20 -1
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_tech.py +7 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/.gitignore +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/LICENSE +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/README.md +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/scripts/build_cve_db.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/__main__.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/__init__.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/creds.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/infra.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/osdetect.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/security.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/services.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/social.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/tech.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/analyzers/vibe.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/config/__init__.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/config/sigdb_loader.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/core/__init__.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/data/builtin.sigdb +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/data/cve.json.gz +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/data/reekeer-logo.png +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/data/subdomains.txt +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/embed.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/net/__init__.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/net/dns.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/net/geo.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/net/ipinfo.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/net/resolver.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/net/subdomains.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/net/tld.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/net/tls.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/net/whois.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/scanners/__init__.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/scanners/isp_blocked.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/scanners/takeover.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/theme.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/types/__init__.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/types/models.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/types/output.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/src/stackscan/utils/paths.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_brute.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_builtin_sigdb.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_creds.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_dns_records.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_embed.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_infra.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_osdetect.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_reekeer_host.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_services.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_social.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_subdomains.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_theme.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_vhost.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_vibe.py +0 -0
- {stackscan-2.7.2 → stackscan-2.7.3}/tests/test_whois.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: stackscan
|
|
3
|
-
Version: 2.7.
|
|
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
|
|
@@ -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'
|
|
@@ -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"]
|
|
@@ -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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
199
|
-
|
|
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
|
-
|
|
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(
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
70
|
+
charset = _charset(resp.charset)
|
|
58
71
|
body_bytes = await resp.content.read(max_bytes)
|
|
59
|
-
|
|
60
|
-
|
|
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 =
|
|
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 = {
|
|
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
|
|
497
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
112
|
-
if
|
|
113
|
-
version =
|
|
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
|
-
|
|
231
|
-
if not
|
|
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
|
-
|
|
248
|
-
if not
|
|
250
|
+
data = await _read_bytes(reader, timeout)
|
|
251
|
+
if not data:
|
|
249
252
|
return (None, None)
|
|
250
|
-
server = fingerprint_http(
|
|
253
|
+
server = fingerprint_http(data.decode("utf-8", "replace"))
|
|
251
254
|
if server != (None, None):
|
|
252
255
|
return server
|
|
253
256
|
return ("RTSP", None)
|