stackscan 2.7.2__py3-none-any.whl → 2.7.3__py3-none-any.whl
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/__init__.py +1 -1
- stackscan/analyzers/cve.py +26 -3
- stackscan/analyzers/exposure.py +26 -6
- stackscan/analyzers/generic.py +18 -9
- stackscan/cli.py +10 -9
- stackscan/config/sources.py +12 -2
- stackscan/core/core.py +20 -3
- stackscan/export.py +20 -6
- stackscan/net/fingerprint.py +12 -10
- stackscan/net/ports.py +11 -8
- stackscan/render.py +42 -33
- stackscan/runner.py +15 -4
- stackscan/scan.py +14 -5
- stackscan/scanners/secrets.py +24 -1
- stackscan/utils/__init__.py +2 -1
- stackscan/utils/urls.py +15 -0
- {stackscan-2.7.2.dist-info → stackscan-2.7.3.dist-info}/METADATA +1 -2
- {stackscan-2.7.2.dist-info → stackscan-2.7.3.dist-info}/RECORD +21 -21
- {stackscan-2.7.2.dist-info → stackscan-2.7.3.dist-info}/WHEEL +0 -0
- {stackscan-2.7.2.dist-info → stackscan-2.7.3.dist-info}/entry_points.txt +0 -0
- {stackscan-2.7.2.dist-info → stackscan-2.7.3.dist-info}/licenses/LICENSE +0 -0
stackscan/__init__.py
CHANGED
stackscan/analyzers/cve.py
CHANGED
|
@@ -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
|
stackscan/analyzers/exposure.py
CHANGED
|
@@ -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
|
|
stackscan/analyzers/generic.py
CHANGED
|
@@ -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:
|
stackscan/cli.py
CHANGED
|
@@ -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:
|
stackscan/config/sources.py
CHANGED
|
@@ -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,
|
stackscan/core/core.py
CHANGED
|
@@ -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
|
stackscan/export.py
CHANGED
|
@@ -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
|
}}
|
stackscan/net/fingerprint.py
CHANGED
|
@@ -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)
|
stackscan/net/ports.py
CHANGED
|
@@ -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)
|
stackscan/render.py
CHANGED
|
@@ -8,6 +8,7 @@ from functools import lru_cache
|
|
|
8
8
|
from urllib.parse import urljoin
|
|
9
9
|
|
|
10
10
|
from rich.console import Console, Group, RenderableType
|
|
11
|
+
from rich.markup import escape
|
|
11
12
|
from rich.panel import Panel
|
|
12
13
|
from rich.table import Table
|
|
13
14
|
from rich.text import Text
|
|
@@ -144,7 +145,7 @@ def _network_section(report: ScanReport) -> RenderableType | None:
|
|
|
144
145
|
ttl = ""
|
|
145
146
|
if rrtype in dns_ttl:
|
|
146
147
|
ttl = str(dns_ttl[rrtype])
|
|
147
|
-
table.add_row(rrtype, target, value, ttl)
|
|
148
|
+
table.add_row(rrtype, escape(target), escape(value), ttl)
|
|
148
149
|
return table
|
|
149
150
|
|
|
150
151
|
|
|
@@ -159,7 +160,7 @@ def _whois_section(report: ScanReport) -> RenderableType | None:
|
|
|
159
160
|
if registrar and whois.registrar_url:
|
|
160
161
|
registrar += f" · {whois.registrar_url}"
|
|
161
162
|
if registrar:
|
|
162
|
-
grid.add_row("Registrar", registrar)
|
|
163
|
+
grid.add_row("Registrar", escape(registrar))
|
|
163
164
|
if whois.registrant_public and whois.registrant:
|
|
164
165
|
grid.add_row("Registrant", Text(whois.registrant, style=theme.WARN))
|
|
165
166
|
elif whois.privacy:
|
|
@@ -172,9 +173,9 @@ def _whois_section(report: ScanReport) -> RenderableType | None:
|
|
|
172
173
|
if whois.expires:
|
|
173
174
|
dates.append(f"expires {whois.expires[:10]}")
|
|
174
175
|
if dates:
|
|
175
|
-
grid.add_row("Dates", " · ".join(dates))
|
|
176
|
+
grid.add_row("Dates", escape(" · ".join(dates)))
|
|
176
177
|
if whois.nameservers:
|
|
177
|
-
grid.add_row("Nameservers", ", ".join(whois.nameservers))
|
|
178
|
+
grid.add_row("Nameservers", escape(", ".join(whois.nameservers)))
|
|
178
179
|
if whois.dnssec:
|
|
179
180
|
style = theme.SUCCESS if whois.dnssec == "signed" else theme.MUTED
|
|
180
181
|
grid.add_row("DNSSEC", Text(whois.dnssec, style=style))
|
|
@@ -270,7 +271,7 @@ def _tls_section(report: ScanReport) -> RenderableType | None:
|
|
|
270
271
|
issued_by = _issuer_org(tls.issuer)
|
|
271
272
|
if ca_kind:
|
|
272
273
|
issued_by += f" · {ca_kind}"
|
|
273
|
-
grid.add_row("Issued by", issued_by)
|
|
274
|
+
grid.add_row("Issued by", escape(issued_by))
|
|
274
275
|
expiry = _cert_expiry(tls.not_after)
|
|
275
276
|
if expiry is not None:
|
|
276
277
|
grid.add_row("Expires", expiry)
|
|
@@ -278,14 +279,14 @@ def _tls_section(report: ScanReport) -> RenderableType | None:
|
|
|
278
279
|
grid.add_row("Issued", Text(tls.not_before, style=theme.MUTED))
|
|
279
280
|
proto = " ".join(filter(None, (tls.protocol, tls.cipher)))
|
|
280
281
|
if proto:
|
|
281
|
-
grid.add_row("Cipher", proto)
|
|
282
|
+
grid.add_row("Cipher", escape(proto))
|
|
282
283
|
if tls.alpn:
|
|
283
|
-
grid.add_row("ALPN", tls.alpn)
|
|
284
|
+
grid.add_row("ALPN", escape(tls.alpn))
|
|
284
285
|
if tls.subject_alt_names:
|
|
285
286
|
sans = ", ".join(tls.subject_alt_names[:8])
|
|
286
287
|
if len(tls.subject_alt_names) > 8:
|
|
287
288
|
sans += f" (+{len(tls.subject_alt_names) - 8} more)"
|
|
288
|
-
grid.add_row("SANs", sans)
|
|
289
|
+
grid.add_row("SANs", escape(sans))
|
|
289
290
|
if report.protocols:
|
|
290
291
|
grid.add_row("HTTP", " · ".join(report.protocols))
|
|
291
292
|
if not grid.row_count:
|
|
@@ -348,10 +349,10 @@ def _tech_section(report: ScanReport) -> RenderableType | None:
|
|
|
348
349
|
for key in order:
|
|
349
350
|
group = groups[key]
|
|
350
351
|
table.add_row(
|
|
351
|
-
group.name,
|
|
352
|
-
group.version or "-",
|
|
353
|
-
group.category,
|
|
354
|
-
group.hosts_cell(primary),
|
|
352
|
+
escape(group.name),
|
|
353
|
+
escape(group.version or "-"),
|
|
354
|
+
escape(group.category),
|
|
355
|
+
escape(group.hosts_cell(primary)),
|
|
355
356
|
group.conf_cell(),
|
|
356
357
|
)
|
|
357
358
|
return table
|
|
@@ -425,12 +426,12 @@ def _services_section(report: ScanReport) -> RenderableType | None:
|
|
|
425
426
|
product = " ".join(filter(None, (port.product, port.version)))
|
|
426
427
|
table.add_row(
|
|
427
428
|
f"{port.port}/{port.protocol}",
|
|
428
|
-
port.host or "-",
|
|
429
|
-
_service_cell(product, port.service),
|
|
429
|
+
escape(port.host or "-"),
|
|
430
|
+
escape(_service_cell(product, port.service)),
|
|
430
431
|
_sev_text(severity),
|
|
431
432
|
)
|
|
432
433
|
for svc in tech_services:
|
|
433
|
-
table.add_row("-", "-", _service_cell(svc.name, svc.kind), _sev_text(svc.severity))
|
|
434
|
+
table.add_row("-", "-", escape(_service_cell(svc.name, svc.kind)), _sev_text(svc.severity))
|
|
434
435
|
|
|
435
436
|
if report.ports is not None:
|
|
436
437
|
return Group(table, Text(f"via {report.ports.scanner}", style="dim"))
|
|
@@ -465,10 +466,10 @@ def _cve_section(report: ScanReport) -> RenderableType | None:
|
|
|
465
466
|
summary = f"{cve.caveat} — {summary}"
|
|
466
467
|
table.add_row(
|
|
467
468
|
_severity_text(cve),
|
|
468
|
-
cve.id,
|
|
469
|
-
affects,
|
|
470
|
-
where,
|
|
471
|
-
summary,
|
|
469
|
+
escape(cve.id),
|
|
470
|
+
escape(affects),
|
|
471
|
+
escape(where),
|
|
472
|
+
escape(summary),
|
|
472
473
|
)
|
|
473
474
|
return table
|
|
474
475
|
|
|
@@ -490,10 +491,16 @@ def _ipinfo_section(report: ScanReport) -> RenderableType | None:
|
|
|
490
491
|
table.add_column("Source", overflow="fold")
|
|
491
492
|
for info in ordered:
|
|
492
493
|
location = ", ".join(v for v in (info.city, info.country) if v) or "-"
|
|
493
|
-
org = info.org or info.isp or "-"
|
|
494
|
+
org = escape(info.org or info.isp or "-")
|
|
494
495
|
if info.is_cdn:
|
|
495
496
|
org = f"{org} [dim](CDN/proxy)[/dim]"
|
|
496
|
-
table.add_row(
|
|
497
|
+
table.add_row(
|
|
498
|
+
escape(info.ip),
|
|
499
|
+
escape(location),
|
|
500
|
+
org,
|
|
501
|
+
escape(info.asn or "-"),
|
|
502
|
+
escape(info.source or "-"),
|
|
503
|
+
)
|
|
497
504
|
return table
|
|
498
505
|
|
|
499
506
|
|
|
@@ -513,7 +520,9 @@ def _creds_section(report: ScanReport) -> RenderableType | None:
|
|
|
513
520
|
label = Text(" OPEN / NO AUTH ", style="bold white on red")
|
|
514
521
|
else:
|
|
515
522
|
label = Text("auth required", style="dim")
|
|
516
|
-
table.add_row(
|
|
523
|
+
table.add_row(
|
|
524
|
+
escape(finding.target), label, escape(f"{finding.service} — {finding.detail}")
|
|
525
|
+
)
|
|
517
526
|
return table
|
|
518
527
|
|
|
519
528
|
|
|
@@ -528,10 +537,10 @@ def _secrets_section(report: ScanReport) -> RenderableType | None:
|
|
|
528
537
|
for secret in sorted(report.secrets, key=lambda s: s.severity):
|
|
529
538
|
color = theme.SEVERITY.get(secret.severity.upper(), theme.MUTED)
|
|
530
539
|
table.add_row(
|
|
531
|
-
secret.name,
|
|
532
|
-
secret.value,
|
|
540
|
+
escape(secret.name),
|
|
541
|
+
escape(secret.value),
|
|
533
542
|
Text(f" {secret.severity.upper()} ", style=f"bold {color}"),
|
|
534
|
-
secret.location or "-",
|
|
543
|
+
escape(secret.location or "-"),
|
|
535
544
|
)
|
|
536
545
|
return table
|
|
537
546
|
|
|
@@ -549,11 +558,11 @@ def _takeovers_section(report: ScanReport) -> RenderableType | None:
|
|
|
549
558
|
color = theme.SEVERITY.get(takeover.severity.upper(), theme.MUTED)
|
|
550
559
|
verified = "verified" if takeover.verified else "potential"
|
|
551
560
|
table.add_row(
|
|
552
|
-
takeover.subdomain,
|
|
553
|
-
takeover.service,
|
|
554
|
-
takeover.cname,
|
|
561
|
+
escape(takeover.subdomain),
|
|
562
|
+
escape(takeover.service),
|
|
563
|
+
escape(takeover.cname),
|
|
555
564
|
Text(f" {takeover.severity.upper()} ", style=f"bold {color}"),
|
|
556
|
-
f"{takeover.evidence} ({verified})",
|
|
565
|
+
escape(f"{takeover.evidence} ({verified})"),
|
|
557
566
|
)
|
|
558
567
|
return table
|
|
559
568
|
|
|
@@ -575,9 +584,9 @@ def _subdomains_section(report: ScanReport) -> RenderableType | None:
|
|
|
575
584
|
real = [ip for ip in sub.addresses if ip in report.real_ips]
|
|
576
585
|
real_cell = ", ".join(real) if real else "-"
|
|
577
586
|
if show_real:
|
|
578
|
-
table.add_row(sub.name, addrs, sub.source, real_cell)
|
|
587
|
+
table.add_row(escape(sub.name), escape(addrs), escape(sub.source), escape(real_cell))
|
|
579
588
|
else:
|
|
580
|
-
table.add_row(sub.name, addrs, sub.source)
|
|
589
|
+
table.add_row(escape(sub.name), escape(addrs), escape(sub.source))
|
|
581
590
|
return table if table.row_count else None
|
|
582
591
|
|
|
583
592
|
|
|
@@ -655,9 +664,9 @@ def _report_panel(report: ScanReport) -> Panel:
|
|
|
655
664
|
if report.network is not None:
|
|
656
665
|
ips.extend(report.network.ipv4)
|
|
657
666
|
ips.extend(report.network.ipv6)
|
|
658
|
-
left = f"[bold]{report.final_url or report.url}[/bold]"
|
|
667
|
+
left = f"[bold]{escape(report.final_url or report.url)}[/bold]"
|
|
659
668
|
if ips:
|
|
660
|
-
left += f"\n[dim]{', '.join(ips)}[/dim]"
|
|
669
|
+
left += f"\n[dim]{escape(', '.join(ips))}[/dim]"
|
|
661
670
|
header.add_row(left, right)
|
|
662
671
|
blocks: list[RenderableType] = [header]
|
|
663
672
|
if report.error:
|
stackscan/runner.py
CHANGED
|
@@ -31,7 +31,7 @@ from stackscan.analyzers import TechAnalyzer
|
|
|
31
31
|
from stackscan.config import build_matchers
|
|
32
32
|
from stackscan.core import StackscanSession
|
|
33
33
|
from stackscan.net import GeoProvider
|
|
34
|
-
from stackscan.scan import ScanOptions, scan_target
|
|
34
|
+
from stackscan.scan import ScanOptions, exc_text, scan_target
|
|
35
35
|
from stackscan.types import ScanReport, Technology
|
|
36
36
|
|
|
37
37
|
DEFAULT_BACKEND_URL = "http://localhost:8787"
|
|
@@ -220,6 +220,7 @@ async def _run(cfg: RunnerConfig) -> None:
|
|
|
220
220
|
|
|
221
221
|
async def scan_one(job: dict[str, str]) -> dict[str, Any]:
|
|
222
222
|
url = job["url"]
|
|
223
|
+
job_id = job["id"]
|
|
223
224
|
try:
|
|
224
225
|
report = await scan_target(
|
|
225
226
|
url,
|
|
@@ -231,10 +232,20 @@ async def _run(cfg: RunnerConfig) -> None:
|
|
|
231
232
|
log=None,
|
|
232
233
|
)
|
|
233
234
|
except Exception as exc: # noqa: BLE001 - one bad target must not stop the runner.
|
|
234
|
-
report = ScanReport(url=url, error=
|
|
235
|
-
return report_to_result(report,
|
|
235
|
+
report = ScanReport(url=url, error=exc_text(exc))
|
|
236
|
+
return report_to_result(report, job_id, cfg.runner_id)
|
|
237
|
+
|
|
238
|
+
usable = [job for job in jobs if job.get("url") and job.get("id")]
|
|
239
|
+
skipped = len(jobs) - len(usable)
|
|
240
|
+
if skipped:
|
|
241
|
+
print(f"[!] skipped {skipped} malformed job(s)", flush=True)
|
|
242
|
+
if not usable:
|
|
243
|
+
if cfg.once:
|
|
244
|
+
return
|
|
245
|
+
await asyncio.sleep(cfg.idle_seconds)
|
|
246
|
+
continue
|
|
236
247
|
|
|
237
|
-
results = await asyncio.gather(*(scan_one(job) for job in
|
|
248
|
+
results = await asyncio.gather(*(scan_one(job) for job in usable))
|
|
238
249
|
try:
|
|
239
250
|
accepted = await client.report(list(results))
|
|
240
251
|
except Exception as exc: # noqa: BLE001
|
stackscan/scan.py
CHANGED
|
@@ -55,7 +55,7 @@ from stackscan.types import (
|
|
|
55
55
|
Technology,
|
|
56
56
|
TlsInfo,
|
|
57
57
|
)
|
|
58
|
-
from stackscan.utils import host_of, is_https, is_ip, port_of
|
|
58
|
+
from stackscan.utils import host_of, is_https, is_ip, netloc_of, port_of
|
|
59
59
|
|
|
60
60
|
if TYPE_CHECKING:
|
|
61
61
|
from stackscan.core import StackscanSession
|
|
@@ -72,6 +72,15 @@ def _http_url(host: str, port: int, tls: bool) -> str:
|
|
|
72
72
|
return f"{scheme}://{host}:{port}"
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
def exc_text(exc: BaseException) -> str:
|
|
76
|
+
message = str(exc).strip()
|
|
77
|
+
if message:
|
|
78
|
+
return message
|
|
79
|
+
if isinstance(exc, TimeoutError):
|
|
80
|
+
return "timed out"
|
|
81
|
+
return exc.__class__.__name__
|
|
82
|
+
|
|
83
|
+
|
|
75
84
|
async def _cdn_ips(ips: set[str], *, timeout: float = 8.0, workers: int = 5) -> set[str]:
|
|
76
85
|
import aiohttp
|
|
77
86
|
|
|
@@ -128,7 +137,7 @@ async def _fetch_with_fallback(
|
|
|
128
137
|
)
|
|
129
138
|
except Exception as exc:
|
|
130
139
|
if first_error is None:
|
|
131
|
-
first_error =
|
|
140
|
+
first_error = exc_text(exc)
|
|
132
141
|
continue
|
|
133
142
|
return (fetched, attempt)
|
|
134
143
|
report.error = first_error
|
|
@@ -372,7 +381,7 @@ async def _scan_site(
|
|
|
372
381
|
max_bytes=options.max_bytes,
|
|
373
382
|
)
|
|
374
383
|
except Exception as exc:
|
|
375
|
-
return SiteFinding(url=url, error=
|
|
384
|
+
return SiteFinding(url=url, error=exc_text(exc))
|
|
376
385
|
block_msg = detect_isp_block(url, fetched)
|
|
377
386
|
if block_msg:
|
|
378
387
|
return SiteFinding(url=url, final_url=fetched.url, status=fetched.status, error=block_msg)
|
|
@@ -448,7 +457,7 @@ def _collect_site_candidates(report: ScanReport, limit: int) -> list[str]:
|
|
|
448
457
|
for port in port_scan.ports:
|
|
449
458
|
if port.port not in _HTTP_PORTS:
|
|
450
459
|
continue
|
|
451
|
-
host = port.host or report.network.host if report.network else None
|
|
460
|
+
host = port.host or (report.network.host if report.network else None)
|
|
452
461
|
if not host:
|
|
453
462
|
continue
|
|
454
463
|
tls = port.port in (443, 8443, 2083) or "https" in (port.service or "").lower()
|
|
@@ -989,7 +998,7 @@ async def scan_target(
|
|
|
989
998
|
report.protocols = _http_protocols(fetched, report.tls)
|
|
990
999
|
stage("scanning page for secrets")
|
|
991
1000
|
report.secrets = scan_secrets(
|
|
992
|
-
fetched.body, location=
|
|
1001
|
+
fetched.body, location=netloc_of(fetched.url) if fetched else host or ""
|
|
993
1002
|
)
|
|
994
1003
|
if options.parse_social:
|
|
995
1004
|
stage("extracting social links")
|
stackscan/scanners/secrets.py
CHANGED
|
@@ -81,6 +81,29 @@ def _redact(value: str, keep: int = 6) -> str:
|
|
|
81
81
|
return f"{value[:keep]}...{value[-keep:]}"
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
_PLACEHOLDER_MARKERS: tuple[str, ...] = (
|
|
85
|
+
"example",
|
|
86
|
+
"your_",
|
|
87
|
+
"_here",
|
|
88
|
+
"changeme",
|
|
89
|
+
"placeholder",
|
|
90
|
+
"redacted",
|
|
91
|
+
"dummy",
|
|
92
|
+
"sample",
|
|
93
|
+
"xxxx",
|
|
94
|
+
"<",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_placeholder(value: str) -> bool:
|
|
99
|
+
"""Filter well-known documentation placeholders (e.g. AWS's AKIA...EXAMPLE)."""
|
|
100
|
+
lowered = value.lower()
|
|
101
|
+
if any(marker in lowered for marker in _PLACEHOLDER_MARKERS):
|
|
102
|
+
return True
|
|
103
|
+
stripped = value.strip("*")
|
|
104
|
+
return len(set(stripped)) <= 1
|
|
105
|
+
|
|
106
|
+
|
|
84
107
|
def scan_secrets(body: str, location: str = "") -> list[SecretFinding]:
|
|
85
108
|
"""Return probable secret leaks found in the response body."""
|
|
86
109
|
seen: set[tuple[str, str]] = set()
|
|
@@ -89,7 +112,7 @@ def scan_secrets(body: str, location: str = "") -> list[SecretFinding]:
|
|
|
89
112
|
for match in pattern.regex.finditer(body):
|
|
90
113
|
value = match.group(1) if pattern.regex.groups else match.group(0)
|
|
91
114
|
value = value.strip()
|
|
92
|
-
if len(value) < 4:
|
|
115
|
+
if len(value) < 4 or _is_placeholder(value):
|
|
93
116
|
continue
|
|
94
117
|
key = (pattern.name, value)
|
|
95
118
|
if key in seen:
|
stackscan/utils/__init__.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from .paths import db_dir
|
|
2
|
-
from .urls import expand_cidr, host_of, is_cidr, is_https, is_ip, normalize_url, port_of
|
|
2
|
+
from .urls import expand_cidr, host_of, is_cidr, is_https, is_ip, netloc_of, normalize_url, port_of
|
|
3
3
|
|
|
4
4
|
__all__ = [
|
|
5
5
|
"db_dir",
|
|
@@ -8,6 +8,7 @@ __all__ = [
|
|
|
8
8
|
"is_cidr",
|
|
9
9
|
"is_ip",
|
|
10
10
|
"is_https",
|
|
11
|
+
"netloc_of",
|
|
11
12
|
"normalize_url",
|
|
12
13
|
"port_of",
|
|
13
14
|
]
|
stackscan/utils/urls.py
CHANGED
|
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|
|
3
3
|
import ipaddress
|
|
4
4
|
from urllib.parse import urlparse
|
|
5
5
|
|
|
6
|
+
MAX_CIDR_HOSTS = 65536
|
|
7
|
+
|
|
6
8
|
|
|
7
9
|
def is_cidr(raw: str) -> bool:
|
|
8
10
|
try:
|
|
@@ -14,6 +16,11 @@ def is_cidr(raw: str) -> bool:
|
|
|
14
16
|
|
|
15
17
|
def expand_cidr(raw: str) -> list[str]:
|
|
16
18
|
network = ipaddress.ip_network(raw, strict=False)
|
|
19
|
+
if network.num_addresses > MAX_CIDR_HOSTS:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"{raw} expands to {network.num_addresses} addresses"
|
|
22
|
+
f" (limit {MAX_CIDR_HOSTS}); use a smaller prefix"
|
|
23
|
+
)
|
|
17
24
|
return [str(host) for host in network]
|
|
18
25
|
|
|
19
26
|
|
|
@@ -36,6 +43,14 @@ def host_of(url: str) -> str:
|
|
|
36
43
|
return (parsed.hostname or "").lower()
|
|
37
44
|
|
|
38
45
|
|
|
46
|
+
def netloc_of(url: str) -> str:
|
|
47
|
+
parsed = urlparse(url if "://" in url else "https://" + url)
|
|
48
|
+
host = (parsed.hostname or "").lower()
|
|
49
|
+
if parsed.port is not None:
|
|
50
|
+
return f"{host}:{parsed.port}"
|
|
51
|
+
return host
|
|
52
|
+
|
|
53
|
+
|
|
39
54
|
def port_of(url: str) -> int:
|
|
40
55
|
parsed = urlparse(url)
|
|
41
56
|
if parsed.port is not None:
|
|
@@ -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'
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
stackscan/__init__.py,sha256=
|
|
1
|
+
stackscan/__init__.py,sha256=MUquHzamgIKyOfbGzsCOwhtMxgqZHGVuW5QOFjpAj8o,154
|
|
2
2
|
stackscan/__main__.py,sha256=M0ngbLh7EFeMvN_A2pxLpnwpk1xudaIQotUxoguvk_k,88
|
|
3
|
-
stackscan/cli.py,sha256=
|
|
3
|
+
stackscan/cli.py,sha256=Icy5T9_OrcrVlE77l9M3V4hpalDEvPGCYLI7rKf9kX4,47198
|
|
4
4
|
stackscan/embed.py,sha256=S-yT0jJJfK3R6U1FodFnVMTXuvdoRAIhNjvOLiWQoAo,15655
|
|
5
|
-
stackscan/export.py,sha256=
|
|
6
|
-
stackscan/render.py,sha256=
|
|
7
|
-
stackscan/runner.py,sha256=
|
|
8
|
-
stackscan/scan.py,sha256=
|
|
5
|
+
stackscan/export.py,sha256=RZhRcjzpDN2a9xC8lHIJFmQbx_bYFsvSWANyg7n5ECQ,27958
|
|
6
|
+
stackscan/render.py,sha256=MzF_9FXwoeJ9JhuYEDVYuDBrMNFGSKbaPaKr207cL08,26505
|
|
7
|
+
stackscan/runner.py,sha256=teP15NF4qApWJn7BXGR0q7YHB9bZWyVftqKf-7VyDCU,10592
|
|
8
|
+
stackscan/scan.py,sha256=MPkuGzvNhaAth31Lk_CvKUOy-gjjbRLTY4DTupt6HGs,39634
|
|
9
9
|
stackscan/theme.py,sha256=aykB2Yjjjgekh89Rb872SIRMMWIFP6ANSwLTrwEZ9Dw,900
|
|
10
10
|
stackscan/analyzers/__init__.py,sha256=2DhG7FCOvSJ2OSo_FU16iQ9M74UfiRZLzudSCorPTAE,1051
|
|
11
11
|
stackscan/analyzers/creds.py,sha256=U054T4-njg0Eqpgkq7QSBk_nEZqsgmMqW4KGi0rdePU,12925
|
|
12
|
-
stackscan/analyzers/cve.py,sha256=
|
|
13
|
-
stackscan/analyzers/exposure.py,sha256=
|
|
14
|
-
stackscan/analyzers/generic.py,sha256=
|
|
12
|
+
stackscan/analyzers/cve.py,sha256=F3fJAV8gOZVv8Big-og8R7dUrhApmVhrtwhZO5jLbeA,20049
|
|
13
|
+
stackscan/analyzers/exposure.py,sha256=2f0wY3LoynlrlzzE_tU3drXokLPFVfyKz3MfAGnZ56w,2861
|
|
14
|
+
stackscan/analyzers/generic.py,sha256=aBCBdDNaAMftvWX2B1JRwjgcRVi0_Sk12eMRhi37yyo,9336
|
|
15
15
|
stackscan/analyzers/infra.py,sha256=O6b_oV18ikK86fJsSjXriFKf6jWMVxkEBeO2ZhQM3uY,5922
|
|
16
16
|
stackscan/analyzers/osdetect.py,sha256=E5VN-ajjFAdaRb6Q2gZZvwEMX4YlzJyrNB_JQSS2Ezw,3830
|
|
17
17
|
stackscan/analyzers/security.py,sha256=2Bp1owKVHYcRN3kU_2jX40f4QA5vbB_jg9-88hp7cvs,743
|
|
@@ -21,19 +21,19 @@ stackscan/analyzers/tech.py,sha256=aLwsSss2od3Cinl-anfncEm2dpUBUFQLKI6JC0JT1mc,1
|
|
|
21
21
|
stackscan/analyzers/vibe.py,sha256=K-pR3ysu_2Ho9WTSIUmN0DzQIBaiTaPZ97ozWmv6dKw,6142
|
|
22
22
|
stackscan/config/__init__.py,sha256=Q17mdAjUBdBxciadsZ6_apuGa03w0VdDR9p8ADKS-XM,596
|
|
23
23
|
stackscan/config/sigdb_loader.py,sha256=Honi_wSFDNCgYP3A2o3RsRWZFX_mcCg9Y1B8hiU0ymQ,2261
|
|
24
|
-
stackscan/config/sources.py,sha256
|
|
24
|
+
stackscan/config/sources.py,sha256=_kPcT175pYUbFagsGHo88JSrEH8gAiCwStawQJQhI7M,14446
|
|
25
25
|
stackscan/core/__init__.py,sha256=dpYyqXnO3tFU4M5z0mjTAGovO4px6IRwDVtOAl7JKBE,67
|
|
26
|
-
stackscan/core/core.py,sha256=
|
|
26
|
+
stackscan/core/core.py,sha256=_rTOyK2eBwWORROfh7gK6usZXwcNsgFnapHkjT9pZr4,2860
|
|
27
27
|
stackscan/data/builtin.sigdb,sha256=DM_pdoRzhUArxl5PDzytCHgP2ia7FaaQ6_9WbE16H7g,1579340
|
|
28
28
|
stackscan/data/cve.json.gz,sha256=LkohNP0Gk66OrgHCuCbNNRESkPXHg150CP_wibszmnI,635760
|
|
29
29
|
stackscan/data/reekeer-logo.png,sha256=usimpkXaxOgnKbGIY6uLpD1Y9uefQYBLJKzBbOI-EV8,8788
|
|
30
30
|
stackscan/data/subdomains.txt,sha256=91RWQUO44BRLWI6eAvo0cSy9MmBOh0EL4ddXYcv7DDg,3894
|
|
31
31
|
stackscan/net/__init__.py,sha256=3LK09_4faQ9NeBjp_wbi7jlBROyZf1aENCmIFslJRWI,781
|
|
32
32
|
stackscan/net/dns.py,sha256=4tPmWexu73e_ZiKme7w_fRsFUEgsKGGogm33jzZM7L4,8034
|
|
33
|
-
stackscan/net/fingerprint.py,sha256=
|
|
33
|
+
stackscan/net/fingerprint.py,sha256=gq_0dsP8d1evhy-cRbwgA8CQgGdcgB7GsJv1o4W3ef4,5245
|
|
34
34
|
stackscan/net/geo.py,sha256=W2Ze5_hpko5R-hULAFdi_SDlSFHC0Ub0KoAA34ffB5o,2225
|
|
35
35
|
stackscan/net/ipinfo.py,sha256=2p0wdBrhhWM1ixScNmWG5cZjLy18NAZb2Na5JARUY0o,2796
|
|
36
|
-
stackscan/net/ports.py,sha256=
|
|
36
|
+
stackscan/net/ports.py,sha256=Bz6YCMOjukx9H5zu5PMADiuubH4qELrT3Ulr9igXeSQ,8332
|
|
37
37
|
stackscan/net/resolver.py,sha256=j4gNmxU0KB3ww1p-OycJxu3SY5y__x0Nqu1ODgViLCk,1905
|
|
38
38
|
stackscan/net/subdomains.py,sha256=tiPbpK3dqNhoKKjkl8Gy68puqZt_Es_jxK-tk-drUpM,17120
|
|
39
39
|
stackscan/net/tld.py,sha256=O5RtZggsYB6olQfd5JtEeOtbxGs7DonOSBxKFCWsans,4239
|
|
@@ -41,16 +41,16 @@ stackscan/net/tls.py,sha256=aAkWHzyst2fgbdS7ElSvPFF5085Bu5Bke441bsH3gbw,3488
|
|
|
41
41
|
stackscan/net/whois.py,sha256=mCpozxa92kZBf-f7aLpgzSzVBbCBSoYQNnczpOe9Seg,5947
|
|
42
42
|
stackscan/scanners/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
43
43
|
stackscan/scanners/isp_blocked.py,sha256=yS_qlrozWSH-NdwDjOFdMURXj-cADb4xFJXh4FfKkNA,4313
|
|
44
|
-
stackscan/scanners/secrets.py,sha256=
|
|
44
|
+
stackscan/scanners/secrets.py,sha256=h0RuBrfZ2Cxwc-p6Tkau4DWCP1_amhyTsV0WpQNQo0I,3978
|
|
45
45
|
stackscan/scanners/takeover.py,sha256=P3rnBCkakwuTG00fD0I32lfcfaKS1t48mQtbcaKA1EA,6533
|
|
46
46
|
stackscan/types/__init__.py,sha256=RGFZNxPzHxUuQdwazXgDfXn8mTBveeHlEWxsqSxVLIA,954
|
|
47
47
|
stackscan/types/models.py,sha256=YYVzn8j79Jermhn62CRIRL0OyprDm21ritchffaIuu4,386
|
|
48
48
|
stackscan/types/output.py,sha256=eytLhJhlX4Ah5ALaY8ViCBd8QvMFh-GBeAxHBWUiYIQ,15681
|
|
49
|
-
stackscan/utils/__init__.py,sha256=
|
|
49
|
+
stackscan/utils/__init__.py,sha256=EW0zbCynB3qxOo2P1C6-hgTXbwCZHXuaFOxRe4-a1vg,286
|
|
50
50
|
stackscan/utils/paths.py,sha256=R1vT7tCwZk1x6yS-R9pETD0qhlsflJR9_MjHDTxdKwg,234
|
|
51
|
-
stackscan/utils/urls.py,sha256=
|
|
52
|
-
stackscan-2.7.
|
|
53
|
-
stackscan-2.7.
|
|
54
|
-
stackscan-2.7.
|
|
55
|
-
stackscan-2.7.
|
|
56
|
-
stackscan-2.7.
|
|
51
|
+
stackscan/utils/urls.py,sha256=owUr2aoPZqaVnJmUIUZ3SDPVQ1jJlxHg2Mo8UHoNgRQ,1493
|
|
52
|
+
stackscan-2.7.3.dist-info/METADATA,sha256=D3y7PJm5bNeZcsiprQkii2Vf01UiUkhgOSxbvaFMa0c,16494
|
|
53
|
+
stackscan-2.7.3.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
54
|
+
stackscan-2.7.3.dist-info/entry_points.txt,sha256=-wD-04qhZHxoi6M7j1T6Pmgq3cKNoVkWebLvAucSfHY,49
|
|
55
|
+
stackscan-2.7.3.dist-info/licenses/LICENSE,sha256=5Ud3RkJNdrhzBthwtmOMDLx2cGd7wjaGLpi8iHYN2p0,1065
|
|
56
|
+
stackscan-2.7.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|