stackscan 2.5.0__tar.gz → 2.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {stackscan-2.5.0 → stackscan-2.6.0}/PKG-INFO +1 -1
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/__init__.py +1 -1
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/osdetect.py +1 -3
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/tech.py +16 -55
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/cli.py +8 -8
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/export.py +13 -44
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/dns.py +5 -1
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/fingerprint.py +1 -7
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/whois.py +39 -1
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/render.py +26 -77
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/scan.py +46 -37
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/scanners/secrets.py +7 -14
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/scanners/takeover.py +3 -4
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/theme.py +3 -2
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/types/output.py +8 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_brute.py +1 -3
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_cli_helpers.py +3 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_dns_records.py +17 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_export.py +49 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_ports.py +1 -3
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_scan.py +19 -2
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_services.py +2 -6
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_whois.py +16 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/.gitignore +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/LICENSE +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/README.md +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/pyproject.toml +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/scripts/build_cve_db.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/__main__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/creds.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/cve.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/exposure.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/generic.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/infra.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/security.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/services.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/social.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/vibe.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/config/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/config/sigdb_loader.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/config/sources.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/core/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/core/core.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/data/builtin.sigdb +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/data/cve.json.gz +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/data/reekeer-logo.png +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/data/subdomains.txt +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/geo.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/ipinfo.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/ports.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/resolver.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/subdomains.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/tld.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/tls.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/scanners/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/scanners/isp_blocked.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/types/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/types/models.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/utils/__init__.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/utils/paths.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/utils/urls.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_builtin_sigdb.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_creds.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_cve.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_extra.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_infra.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_osdetect.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_scanners.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_social.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_sources.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_subdomains.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_tech.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_theme.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_vhost.py +0 -0
- {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_vibe.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: stackscan
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.6.0
|
|
4
4
|
Summary: Full web stack, infrastructure, DNS, port, subdomain and CVE analyzer.
|
|
5
5
|
Project-URL: Source, https://github.com/reekeer/stackscan
|
|
6
6
|
Project-URL: Issues, https://github.com/reekeer/stackscan/issues
|
|
@@ -106,9 +106,7 @@ def detect_os(report: ScanReport) -> list[OsFinding]:
|
|
|
106
106
|
for os, src in pairs:
|
|
107
107
|
by_source.setdefault(src, []).append(os)
|
|
108
108
|
dominant_source = max(by_source, key=lambda s: len(by_source[s]))
|
|
109
|
-
if dominant_source == "port-banner" or dominant_source.startswith(
|
|
110
|
-
("header:", "meta:", "script")
|
|
111
|
-
):
|
|
109
|
+
if dominant_source == "port-banner" or dominant_source.startswith(("header:", "meta:", "script")):
|
|
112
110
|
category = "banner"
|
|
113
111
|
elif dominant_source.startswith("port "):
|
|
114
112
|
category = "network"
|
|
@@ -40,51 +40,17 @@ def _script_srcs(html: str) -> list[str]:
|
|
|
40
40
|
_CLASS_ATTR_RE = re.compile(r'\bclass\s*=\s*"([^"]*)"|\bclass\s*=\s*\'([^\']*)\'', re.I)
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
_CSS_UTILITY_EXACT: frozenset[str] = frozenset(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"table-row",
|
|
55
|
-
"flow-root",
|
|
56
|
-
"contents",
|
|
57
|
-
"float-left",
|
|
58
|
-
"float-right",
|
|
59
|
-
"float-none",
|
|
60
|
-
"clear-left",
|
|
61
|
-
"clear-right",
|
|
62
|
-
"clear-both",
|
|
63
|
-
"clear-none",
|
|
64
|
-
"isolate",
|
|
65
|
-
"isolation-auto",
|
|
66
|
-
"object-contain",
|
|
67
|
-
"object-cover",
|
|
68
|
-
"object-fill",
|
|
69
|
-
"object-none",
|
|
70
|
-
"object-scale-down",
|
|
71
|
-
"overflow-auto",
|
|
72
|
-
"overflow-hidden",
|
|
73
|
-
"overflow-visible",
|
|
74
|
-
"overflow-scroll",
|
|
75
|
-
"overscroll-auto",
|
|
76
|
-
"overscroll-contain",
|
|
77
|
-
"overscroll-none",
|
|
78
|
-
"visible",
|
|
79
|
-
"invisible",
|
|
80
|
-
"collapse",
|
|
81
|
-
"static",
|
|
82
|
-
"fixed",
|
|
83
|
-
"absolute",
|
|
84
|
-
"relative",
|
|
85
|
-
"sticky",
|
|
86
|
-
}
|
|
87
|
-
)
|
|
43
|
+
_CSS_UTILITY_EXACT: frozenset[str] = frozenset({
|
|
44
|
+
"container", "flex", "grid", "block", "inline", "inline-block", "hidden",
|
|
45
|
+
"table", "table-cell", "table-row", "flow-root", "contents",
|
|
46
|
+
"float-left", "float-right", "float-none", "clear-left", "clear-right",
|
|
47
|
+
"clear-both", "clear-none", "isolate", "isolation-auto",
|
|
48
|
+
"object-contain", "object-cover", "object-fill", "object-none", "object-scale-down",
|
|
49
|
+
"overflow-auto", "overflow-hidden", "overflow-visible", "overflow-scroll",
|
|
50
|
+
"overscroll-auto", "overscroll-contain", "overscroll-none",
|
|
51
|
+
"visible", "invisible", "collapse",
|
|
52
|
+
"static", "fixed", "absolute", "relative", "sticky",
|
|
53
|
+
})
|
|
88
54
|
# Tailwind-style utilities: px-4, w-full, bg-red-500, my-auto, backdrop-blur, etc.
|
|
89
55
|
_CSS_UTILITY_RE = re.compile(
|
|
90
56
|
r"^[a-z]+(-[a-z]+)?-(\d+|auto|full|screen|px|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|8xl|9xl|none|hidden|visible|inherit|current|transparent|black|white|blur|opacity|saturate|sepia|grayscale|contrast|brightness|invert|drop-shadow|hue-rotate|shadow|sm|md|lg|xl)$",
|
|
@@ -152,9 +118,7 @@ def _confidence(evidence: list[str]) -> int:
|
|
|
152
118
|
|
|
153
119
|
|
|
154
120
|
_VERSION_RE = re.compile(r"(\d+\.\d+(?:\.\d+)?(?:[-+.]?[a-zA-Z0-9]+)?)")
|
|
155
|
-
_CORE_COMMIT_RE = re.compile(
|
|
156
|
-
r"([A-Za-z][A-Za-z0-9\s-]*?)\s+Core\s+\(([a-f0-9]{4,})\)", re.IGNORECASE
|
|
157
|
-
)
|
|
121
|
+
_CORE_COMMIT_RE = re.compile(r"([A-Za-z][A-Za-z0-9\s-]*?)\s+Core\s+\(([a-f0-9]{4,})\)", re.IGNORECASE)
|
|
158
122
|
|
|
159
123
|
|
|
160
124
|
def _version_key(version: str) -> tuple[int, int, int, int]:
|
|
@@ -310,7 +274,9 @@ class TechAnalyzer:
|
|
|
310
274
|
self._add(acc, matcher.match(result.body), "body")
|
|
311
275
|
self._add(acc, matcher.match(result.url), "url")
|
|
312
276
|
for token in _framework_tokens(result.body):
|
|
313
|
-
self._add(
|
|
277
|
+
self._add(
|
|
278
|
+
acc, matcher.match_search({"framework": token}), f"framework:{token}"
|
|
279
|
+
)
|
|
314
280
|
self._curated(acc, result)
|
|
315
281
|
by_name: dict[str, _Hit] = {}
|
|
316
282
|
for hit in acc.values():
|
|
@@ -370,9 +336,4 @@ class TechAnalyzer:
|
|
|
370
336
|
add(name, "service", f"body:{name} Core ({commit})", version=commit)
|
|
371
337
|
|
|
372
338
|
for tech in extract_generic_tech(result.body):
|
|
373
|
-
add(
|
|
374
|
-
tech.name,
|
|
375
|
-
tech.categories[0] if tech.categories else "service",
|
|
376
|
-
tech.evidence[0],
|
|
377
|
-
tech.version,
|
|
378
|
-
)
|
|
339
|
+
add(tech.name, tech.categories[0] if tech.categories else "service", tech.evidence[0], tech.version)
|
|
@@ -341,14 +341,14 @@ class _StageTracker:
|
|
|
341
341
|
self._progress.update(
|
|
342
342
|
self._task_id,
|
|
343
343
|
advance=1,
|
|
344
|
-
description=f"
|
|
344
|
+
description=f"{_glyphs().run} {self._target} {_glyphs().bullet} {message}",
|
|
345
345
|
)
|
|
346
346
|
self._title(message)
|
|
347
347
|
|
|
348
348
|
def info(self, message: str) -> None:
|
|
349
349
|
self._progress.update(
|
|
350
350
|
self._task_id,
|
|
351
|
-
description=f"
|
|
351
|
+
description=f"{_glyphs().run} {self._target} {_glyphs().bullet} {message}",
|
|
352
352
|
)
|
|
353
353
|
self._title(message)
|
|
354
354
|
|
|
@@ -363,7 +363,7 @@ class _StageTracker:
|
|
|
363
363
|
self._progress.update(
|
|
364
364
|
self._task_id,
|
|
365
365
|
advance=steps,
|
|
366
|
-
description=f"
|
|
366
|
+
description=f"{_glyphs().run} {self._target} {_glyphs().bullet} {message}",
|
|
367
367
|
)
|
|
368
368
|
self._title(message)
|
|
369
369
|
|
|
@@ -427,7 +427,6 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
|
|
|
427
427
|
from rich.progress import (
|
|
428
428
|
BarColumn,
|
|
429
429
|
MofNCompleteColumn,
|
|
430
|
-
SpinnerColumn,
|
|
431
430
|
TextColumn,
|
|
432
431
|
TimeElapsedColumn,
|
|
433
432
|
)
|
|
@@ -435,7 +434,7 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
|
|
|
435
434
|
err_console = Console(stderr=True)
|
|
436
435
|
total_targets = len(targets)
|
|
437
436
|
completed = 0
|
|
438
|
-
staged = args.verbose >=
|
|
437
|
+
staged = args.verbose >= 1 or (args.verbose == 0 and total_targets == 1)
|
|
439
438
|
per_target_total = stage_total(options)
|
|
440
439
|
|
|
441
440
|
async def scan_one(
|
|
@@ -493,7 +492,9 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
|
|
|
493
492
|
_set_title(f"stackscan {completed}/{total_targets} · {target.split('://', 1)[-1]}")
|
|
494
493
|
if progress_obj is not None and task_id is not None:
|
|
495
494
|
if staged:
|
|
496
|
-
progress_obj.update(
|
|
495
|
+
progress_obj.update(
|
|
496
|
+
task_id, completed=target_total, description=f"{_glyphs().ok} {target}"
|
|
497
|
+
)
|
|
497
498
|
else:
|
|
498
499
|
progress_obj.update(task_id, advance=1, description=f"Scanning: {target}")
|
|
499
500
|
|
|
@@ -509,9 +510,8 @@ async def _run_scans(args: argparse.Namespace) -> list[ScanReport]:
|
|
|
509
510
|
_set_title(f"stackscan · scanning {total_targets} target(s)")
|
|
510
511
|
async with StackscanSession() as session:
|
|
511
512
|
if not is_json_terminal():
|
|
512
|
-
transient = args.verbose
|
|
513
|
+
transient = args.verbose == 0
|
|
513
514
|
with Progress(
|
|
514
|
-
SpinnerColumn(),
|
|
515
515
|
TextColumn("[progress.description]{task.description}"),
|
|
516
516
|
BarColumn(),
|
|
517
517
|
MofNCompleteColumn(),
|
|
@@ -9,8 +9,6 @@ from typing import Any
|
|
|
9
9
|
from xml.sax.saxutils import escape
|
|
10
10
|
|
|
11
11
|
from stackscan import __version__, theme
|
|
12
|
-
from stackscan.analyzers import summarize_edge
|
|
13
|
-
from stackscan.types import InfraInfo
|
|
14
12
|
from stackscan.utils import host_of
|
|
15
13
|
|
|
16
14
|
Payload = dict[str, Any]
|
|
@@ -208,15 +206,19 @@ def _html_card(r: dict[str, Any]) -> str:
|
|
|
208
206
|
vals = net.get(key) or []
|
|
209
207
|
if vals:
|
|
210
208
|
rows.append(_kv(label, _chips(vals)))
|
|
209
|
+
for rrtype, vals in sorted((net.get("extras") or {}).items()):
|
|
210
|
+
if vals:
|
|
211
|
+
rows.append(_kv(rrtype, _chips(vals)))
|
|
211
212
|
domains = net.get("domains") or []
|
|
212
213
|
if domains:
|
|
213
214
|
rows.append(_kv("Domains", _chips(sorted(set(domains)))))
|
|
214
215
|
body_parts.append(_section("Network / DNS", "".join(rows)))
|
|
215
216
|
whois = r.get("whois") or {}
|
|
216
217
|
if whois:
|
|
217
|
-
|
|
218
|
-
if whois.get("
|
|
219
|
-
|
|
218
|
+
registrar = whois.get("registrar") or ""
|
|
219
|
+
if registrar and whois.get("registrar_url"):
|
|
220
|
+
registrar += f" · {whois['registrar_url']}"
|
|
221
|
+
rows = _kv("Registrar", _e(registrar)) if registrar else ""
|
|
220
222
|
if whois.get("registrant_public") and whois.get("registrant"):
|
|
221
223
|
rows += _kv("Registrant", _e(whois["registrant"]))
|
|
222
224
|
elif whois.get("privacy"):
|
|
@@ -224,10 +226,16 @@ def _html_card(r: dict[str, Any]) -> str:
|
|
|
224
226
|
dates = []
|
|
225
227
|
if whois.get("created"):
|
|
226
228
|
dates.append(f"registered {_e(str(whois['created'])[:10])}")
|
|
229
|
+
if whois.get("updated"):
|
|
230
|
+
dates.append(f"updated {_e(str(whois['updated'])[:10])}")
|
|
227
231
|
if whois.get("expires"):
|
|
228
232
|
dates.append(f"expires {_e(str(whois['expires'])[:10])}")
|
|
229
233
|
if dates:
|
|
230
234
|
rows += _kv("Dates", " · ".join(dates))
|
|
235
|
+
if whois.get("nameservers"):
|
|
236
|
+
rows += _kv("Nameservers", _chips(whois["nameservers"]))
|
|
237
|
+
if whois.get("dnssec"):
|
|
238
|
+
rows += _kv("DNSSEC", _e(whois["dnssec"]))
|
|
231
239
|
if whois.get("statuses"):
|
|
232
240
|
rows += _kv("Status", _e(", ".join(whois["statuses"])))
|
|
233
241
|
if rows:
|
|
@@ -246,31 +254,6 @@ def _html_card(r: dict[str, Any]) -> str:
|
|
|
246
254
|
for i in ipinfo
|
|
247
255
|
)
|
|
248
256
|
body_parts.append(_section("IP intelligence", rows))
|
|
249
|
-
infra = r.get("infra") or {}
|
|
250
|
-
cdn_orgs = [
|
|
251
|
-
i.get("org") or i.get("isp")
|
|
252
|
-
for i in (r.get("ip_info") or [])
|
|
253
|
-
if i.get("is_cdn") and (i.get("org") or i.get("isp"))
|
|
254
|
-
]
|
|
255
|
-
edge = summarize_edge(
|
|
256
|
-
InfraInfo(
|
|
257
|
-
cdn=tuple(infra.get("cdn") or ()),
|
|
258
|
-
waf=tuple(infra.get("waf") or ()),
|
|
259
|
-
proxy=tuple(infra.get("proxy") or ()),
|
|
260
|
-
server=tuple(infra.get("server") or ()),
|
|
261
|
-
),
|
|
262
|
-
[org for org in cdn_orgs if org],
|
|
263
|
-
)
|
|
264
|
-
infra_rows = _kv("Edge", _e(f"behind {edge}")) if edge else ""
|
|
265
|
-
infra_rows += "".join(
|
|
266
|
-
(
|
|
267
|
-
_kv(label, _chips(infra.get(key) or []))
|
|
268
|
-
for label, key in (("Server", "server"),)
|
|
269
|
-
if infra.get(key)
|
|
270
|
-
)
|
|
271
|
-
)
|
|
272
|
-
if infra_rows:
|
|
273
|
-
body_parts.append(_section("Infrastructure", infra_rows))
|
|
274
257
|
protocols = r.get("protocols") or []
|
|
275
258
|
if protocols:
|
|
276
259
|
body_parts.append(_section("Protocol", _chips(protocols)))
|
|
@@ -316,20 +299,6 @@ def _html_card(r: dict[str, Any]) -> str:
|
|
|
316
299
|
f"<table><thead><tr><th>Service</th><th>Kind</th><th>Severity</th><th>Evidence</th></tr></thead><tbody>{rows}</tbody></table>",
|
|
317
300
|
)
|
|
318
301
|
)
|
|
319
|
-
os_findings = r.get("os_findings") or []
|
|
320
|
-
if os_findings:
|
|
321
|
-
rows = "".join(
|
|
322
|
-
f"<tr><td>{_e(o.get('host'))}</td><td>{_e(o.get('os'))}</td>"
|
|
323
|
-
f"<td>{_e(o.get('service'))}</td><td>{_e(o.get('category'))}</td>"
|
|
324
|
-
f"<td>{_e(o.get('source'))} ({int((o.get('confidence') or 0) * 100)}%)</td></tr>"
|
|
325
|
-
for o in os_findings
|
|
326
|
-
)
|
|
327
|
-
body_parts.append(
|
|
328
|
-
_section(
|
|
329
|
-
"Hosts & OS",
|
|
330
|
-
f"<table><thead><tr><th>Host</th><th>OS</th><th>Service</th><th>Category</th><th>Source</th></tr></thead><tbody>{rows}</tbody></table>",
|
|
331
|
-
)
|
|
332
|
-
)
|
|
333
302
|
software = r.get("software") or []
|
|
334
303
|
if software:
|
|
335
304
|
names = [f"{s.get('name')} {s.get('version')}".strip() for s in software]
|
|
@@ -9,7 +9,9 @@ from typing import Any, cast
|
|
|
9
9
|
|
|
10
10
|
_QUERY_TIMEOUT = 2.5
|
|
11
11
|
_QUERY_LIFETIME = 5.0
|
|
12
|
-
|
|
12
|
+
_CORE_TYPES = ("A", "AAAA", "CNAME", "MX", "NS", "TXT", "SOA", "CAA")
|
|
13
|
+
_EXTRA_TYPES = ("HTTPS", "SVCB", "DS")
|
|
14
|
+
_RECORD_TYPES = _CORE_TYPES + _EXTRA_TYPES
|
|
13
15
|
|
|
14
16
|
_PUBLIC_NAMESERVERS: tuple[str, ...] = (
|
|
15
17
|
"1.1.1.1",
|
|
@@ -173,6 +175,7 @@ def resolve_host(host: str, *, reverse: bool = True) -> DnsResult:
|
|
|
173
175
|
ipv4 = records.get("A", ())
|
|
174
176
|
ipv6 = records.get("AAAA", ())
|
|
175
177
|
reverse_map = _reverse_lookup(dns_mod, (*ipv4, *ipv6)) if reverse else {}
|
|
178
|
+
extras = {rdtype: records[rdtype] for rdtype in _EXTRA_TYPES if records.get(rdtype)}
|
|
176
179
|
return DnsResult(
|
|
177
180
|
host=host,
|
|
178
181
|
ipv4=ipv4,
|
|
@@ -185,6 +188,7 @@ def resolve_host(host: str, *, reverse: bool = True) -> DnsResult:
|
|
|
185
188
|
soa=records.get("SOA", ()),
|
|
186
189
|
caa=records.get("CAA", ()),
|
|
187
190
|
resolver_available=bool(records),
|
|
191
|
+
extras=extras,
|
|
188
192
|
ttl=ttls,
|
|
189
193
|
)
|
|
190
194
|
|
|
@@ -25,13 +25,7 @@ _DISTRO_PATTERNS: tuple[tuple[re.Pattern[str], str, int | None], ...] = (
|
|
|
25
25
|
(re.compile(r"\bubuntu\b", re.I), "Ubuntu", None),
|
|
26
26
|
(re.compile(r"\+?deb(\d+)u?(\d+)?", re.I), "Debian", 1),
|
|
27
27
|
(re.compile(r"\bdebian[_-]?(\d[\d._]*)?", re.I), "Debian", 1),
|
|
28
|
-
(
|
|
29
|
-
re.compile(
|
|
30
|
-
r"\b(red\s*hat(?:\s*enterprise\s*linux|\s*linux)?|rhel)[-_\s]?(\d[\d._]*)?", re.I
|
|
31
|
-
),
|
|
32
|
-
"Red Hat",
|
|
33
|
-
2,
|
|
34
|
-
),
|
|
28
|
+
(re.compile(r"\b(red\s*hat(?:\s*enterprise\s*linux|\s*linux)?|rhel)[-_\s]?(\d[\d._]*)?", re.I), "Red Hat", 2),
|
|
35
29
|
(re.compile(r"\bcentos[-_\s]?(\d[\d._]*)?", re.I), "CentOS", 1),
|
|
36
30
|
(re.compile(r"\bfedora[-_\s]?(\d[\d._]*)?", re.I), "Fedora", 1),
|
|
37
31
|
(re.compile(r"\brocky\s*linux?[-_\s]?(\d[\d._]*)?", re.I), "Rocky Linux", 1),
|
|
@@ -91,6 +91,40 @@ def _registrar_name(entity: dict[str, Any] | None) -> str | None:
|
|
|
91
91
|
return str(handle) if handle else None
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
def _registrar_url(entity: dict[str, Any] | None) -> str | None:
|
|
95
|
+
if not entity:
|
|
96
|
+
return None
|
|
97
|
+
url = _vcard(entity).get("url")
|
|
98
|
+
if url:
|
|
99
|
+
return url
|
|
100
|
+
for link in cast("list[dict[str, Any]]", entity.get("links") or []):
|
|
101
|
+
href = link.get("href")
|
|
102
|
+
if href and link.get("rel") in ("about", "self"):
|
|
103
|
+
return str(href)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _nameservers(data: dict[str, Any]) -> tuple[str, ...]:
|
|
108
|
+
out: list[str] = []
|
|
109
|
+
for ns in cast("list[dict[str, Any]]", data.get("nameservers") or []):
|
|
110
|
+
name = str(ns.get("ldhName") or ns.get("unicodeName") or "").rstrip(".").lower()
|
|
111
|
+
if name and name not in out:
|
|
112
|
+
out.append(name)
|
|
113
|
+
return tuple(out)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _dnssec(data: dict[str, Any]) -> str:
|
|
117
|
+
secure = data.get("secureDNS")
|
|
118
|
+
if not isinstance(secure, dict):
|
|
119
|
+
return ""
|
|
120
|
+
signed = cast("dict[str, Any]", secure).get("delegationSigned")
|
|
121
|
+
if signed is True:
|
|
122
|
+
return "signed"
|
|
123
|
+
if signed is False:
|
|
124
|
+
return "unsigned"
|
|
125
|
+
return ""
|
|
126
|
+
|
|
127
|
+
|
|
94
128
|
def _event(events: list[dict[str, Any]], action: str) -> str | None:
|
|
95
129
|
for event in events:
|
|
96
130
|
if str(event.get("eventAction", "")).lower() == action:
|
|
@@ -121,7 +155,8 @@ def _privacy(
|
|
|
121
155
|
|
|
122
156
|
def _parse_rdap(domain: str, data: dict[str, Any]) -> WhoisInfo:
|
|
123
157
|
entities = cast("list[dict[str, Any]]", data.get("entities") or [])
|
|
124
|
-
|
|
158
|
+
registrar_entity = _find_role(entities, "registrar")
|
|
159
|
+
registrar = _registrar_name(registrar_entity)
|
|
125
160
|
registrant_entity = _find_role(entities, "registrant")
|
|
126
161
|
events = cast("list[dict[str, Any]]", data.get("events") or [])
|
|
127
162
|
statuses = tuple(str(s) for s in cast("list[Any]", data.get("status") or []))
|
|
@@ -129,11 +164,14 @@ def _parse_rdap(domain: str, data: dict[str, Any]) -> WhoisInfo:
|
|
|
129
164
|
return WhoisInfo(
|
|
130
165
|
domain=domain,
|
|
131
166
|
registrar=registrar,
|
|
167
|
+
registrar_url=_registrar_url(registrar_entity),
|
|
132
168
|
registrant=contact,
|
|
133
169
|
registrant_public=public,
|
|
134
170
|
privacy=note,
|
|
135
171
|
created=_event(events, "registration"),
|
|
136
172
|
updated=_event(events, "last changed"),
|
|
137
173
|
expires=_event(events, "expiration"),
|
|
174
|
+
nameservers=_nameservers(data),
|
|
175
|
+
dnssec=_dnssec(data),
|
|
138
176
|
statuses=statuses,
|
|
139
177
|
)
|
|
@@ -12,7 +12,7 @@ from rich.table import Table
|
|
|
12
12
|
from rich.text import Text
|
|
13
13
|
|
|
14
14
|
from stackscan import __version__, theme
|
|
15
|
-
from stackscan.analyzers import port_category
|
|
15
|
+
from stackscan.analyzers import port_category
|
|
16
16
|
from stackscan.types import CveMatch, IpInfo, ScanReport
|
|
17
17
|
from stackscan.utils import host_of
|
|
18
18
|
|
|
@@ -108,6 +108,8 @@ def _network_section(report: ScanReport) -> RenderableType | None:
|
|
|
108
108
|
add("TXT", host, net.txt)
|
|
109
109
|
add("SOA", host, net.soa)
|
|
110
110
|
add("CAA", host, net.caa)
|
|
111
|
+
for rrtype in sorted(net.extras):
|
|
112
|
+
add(rrtype, host, net.extras[rrtype])
|
|
111
113
|
for ip, name in sorted(net.reverse_dns.items(), key=lambda x: _ip_sort_key(x[0])):
|
|
112
114
|
rows.append(("PTR", ip, name))
|
|
113
115
|
if net.geo:
|
|
@@ -145,17 +147,6 @@ def _network_section(report: ScanReport) -> RenderableType | None:
|
|
|
145
147
|
return table
|
|
146
148
|
|
|
147
149
|
|
|
148
|
-
def _cdn_orgs(report: ScanReport) -> list[str]:
|
|
149
|
-
orgs: list[str] = []
|
|
150
|
-
for info in report.ip_info:
|
|
151
|
-
if not info.is_cdn:
|
|
152
|
-
continue
|
|
153
|
-
org = info.org or info.isp
|
|
154
|
-
if org and org not in orgs:
|
|
155
|
-
orgs.append(org)
|
|
156
|
-
return orgs
|
|
157
|
-
|
|
158
|
-
|
|
159
150
|
def _whois_section(report: ScanReport) -> RenderableType | None:
|
|
160
151
|
whois = report.whois
|
|
161
152
|
if whois is None:
|
|
@@ -163,8 +154,11 @@ def _whois_section(report: ScanReport) -> RenderableType | None:
|
|
|
163
154
|
grid = Table.grid(padding=(0, 1))
|
|
164
155
|
grid.add_column(style="bold cyan", no_wrap=True, justify="right")
|
|
165
156
|
grid.add_column(overflow="fold")
|
|
166
|
-
|
|
167
|
-
|
|
157
|
+
registrar = whois.registrar or ""
|
|
158
|
+
if registrar and whois.registrar_url:
|
|
159
|
+
registrar += f" · {whois.registrar_url}"
|
|
160
|
+
if registrar:
|
|
161
|
+
grid.add_row("Registrar", registrar)
|
|
168
162
|
if whois.registrant_public and whois.registrant:
|
|
169
163
|
grid.add_row("Registrant", Text(whois.registrant, style=theme.WARN))
|
|
170
164
|
elif whois.privacy:
|
|
@@ -172,10 +166,17 @@ def _whois_section(report: ScanReport) -> RenderableType | None:
|
|
|
172
166
|
dates: list[str] = []
|
|
173
167
|
if whois.created:
|
|
174
168
|
dates.append(f"registered {whois.created[:10]}")
|
|
169
|
+
if whois.updated:
|
|
170
|
+
dates.append(f"updated {whois.updated[:10]}")
|
|
175
171
|
if whois.expires:
|
|
176
172
|
dates.append(f"expires {whois.expires[:10]}")
|
|
177
173
|
if dates:
|
|
178
174
|
grid.add_row("Dates", " · ".join(dates))
|
|
175
|
+
if whois.nameservers:
|
|
176
|
+
grid.add_row("Nameservers", ", ".join(whois.nameservers))
|
|
177
|
+
if whois.dnssec:
|
|
178
|
+
style = theme.SUCCESS if whois.dnssec == "signed" else theme.MUTED
|
|
179
|
+
grid.add_row("DNSSEC", Text(whois.dnssec, style=style))
|
|
179
180
|
if whois.statuses:
|
|
180
181
|
shown = ", ".join(whois.statuses[:4])
|
|
181
182
|
if len(whois.statuses) > 4:
|
|
@@ -186,21 +187,6 @@ def _whois_section(report: ScanReport) -> RenderableType | None:
|
|
|
186
187
|
return grid
|
|
187
188
|
|
|
188
189
|
|
|
189
|
-
def _infra_section(report: ScanReport) -> RenderableType | None:
|
|
190
|
-
infra = report.infra
|
|
191
|
-
edge = summarize_edge(infra, _cdn_orgs(report), sep=f" {_glyphs().arrow} ")
|
|
192
|
-
if not edge and not infra.notes:
|
|
193
|
-
return None
|
|
194
|
-
grid = Table.grid(padding=(0, 1))
|
|
195
|
-
grid.add_column(style="bold cyan", no_wrap=True, justify="right")
|
|
196
|
-
grid.add_column(overflow="fold")
|
|
197
|
-
if edge:
|
|
198
|
-
grid.add_row("Edge", Text(f"behind {edge}", style=theme.ACCENT_2))
|
|
199
|
-
for note in infra.notes:
|
|
200
|
-
grid.add_row("Note", Text(note, style="dim"))
|
|
201
|
-
return grid
|
|
202
|
-
|
|
203
|
-
|
|
204
190
|
_ACME_CAS: tuple[str, ...] = (
|
|
205
191
|
"let's encrypt",
|
|
206
192
|
"lets encrypt",
|
|
@@ -306,26 +292,26 @@ def _tls_section(report: ScanReport) -> RenderableType | None:
|
|
|
306
292
|
return grid
|
|
307
293
|
|
|
308
294
|
|
|
295
|
+
_EDGE_CATEGORIES: frozenset[str] = frozenset({"edge", "cdn", "waf", "proxy"})
|
|
296
|
+
|
|
297
|
+
|
|
309
298
|
def _tech_section(report: ScanReport) -> RenderableType | None:
|
|
310
|
-
"""Render technologies and extracted software as a flat table."""
|
|
311
299
|
all_techs = report.all_technologies()
|
|
300
|
+
primary = host_of(report.url)
|
|
312
301
|
rows: list[tuple[str, str, str, str, str]] = []
|
|
313
302
|
seen: set[tuple[str, str | None, str]] = set()
|
|
314
303
|
|
|
315
304
|
def _add_row(name: str, version: str | None, category: str, host: str, confidence: int) -> None:
|
|
316
|
-
|
|
305
|
+
is_edge = any(cat in _EDGE_CATEGORIES for cat in category.split(", "))
|
|
306
|
+
host_cell = host or primary or "-"
|
|
307
|
+
if is_edge:
|
|
308
|
+
key = (name.lower(), version, "")
|
|
309
|
+
else:
|
|
310
|
+
key = (name.lower(), version, host)
|
|
317
311
|
if key in seen:
|
|
318
312
|
return
|
|
319
313
|
seen.add(key)
|
|
320
|
-
rows.append(
|
|
321
|
-
(
|
|
322
|
-
name,
|
|
323
|
-
version or "-",
|
|
324
|
-
category,
|
|
325
|
-
host if host and host != host_of(report.url) else "-",
|
|
326
|
-
f"{confidence}%",
|
|
327
|
-
)
|
|
328
|
-
)
|
|
314
|
+
rows.append((name, version or "-", category, host_cell, f"{confidence}%"))
|
|
329
315
|
|
|
330
316
|
for tech in all_techs:
|
|
331
317
|
category = ", ".join(tech.categories) if tech.categories else "uncategorized"
|
|
@@ -548,26 +534,6 @@ def _subdomains_section(report: ScanReport) -> RenderableType | None:
|
|
|
548
534
|
return table if table.row_count else None
|
|
549
535
|
|
|
550
536
|
|
|
551
|
-
def _os_section(report: ScanReport) -> RenderableType | None:
|
|
552
|
-
if not report.os_findings:
|
|
553
|
-
return None
|
|
554
|
-
table = Table(box=None, pad_edge=False)
|
|
555
|
-
table.add_column("Host", style="bold cyan", overflow="fold")
|
|
556
|
-
table.add_column("OS", overflow="fold")
|
|
557
|
-
table.add_column("Service", overflow="fold")
|
|
558
|
-
table.add_column("Category", overflow="fold")
|
|
559
|
-
table.add_column("Source", overflow="fold")
|
|
560
|
-
for finding in report.os_findings:
|
|
561
|
-
table.add_row(
|
|
562
|
-
finding.host,
|
|
563
|
-
finding.os,
|
|
564
|
-
finding.service,
|
|
565
|
-
finding.category,
|
|
566
|
-
f"{finding.source} ({int(finding.confidence * 100)}%)",
|
|
567
|
-
)
|
|
568
|
-
return table
|
|
569
|
-
|
|
570
|
-
|
|
571
537
|
def _social_section(report: ScanReport) -> RenderableType | None:
|
|
572
538
|
if not report.social:
|
|
573
539
|
return None
|
|
@@ -583,20 +549,6 @@ def _social_section(report: ScanReport) -> RenderableType | None:
|
|
|
583
549
|
return grid
|
|
584
550
|
|
|
585
551
|
|
|
586
|
-
def _security_section(report: ScanReport) -> RenderableType | None:
|
|
587
|
-
sec = report.security
|
|
588
|
-
if not sec.present and (not sec.missing):
|
|
589
|
-
return None
|
|
590
|
-
grid = Table.grid(padding=(0, 1))
|
|
591
|
-
grid.add_column(style="bold cyan", no_wrap=True, justify="right")
|
|
592
|
-
grid.add_column(overflow="fold")
|
|
593
|
-
if sec.present:
|
|
594
|
-
grid.add_row("Present", Text(", ".join(sorted(sec.present)), style="green"))
|
|
595
|
-
if sec.missing:
|
|
596
|
-
grid.add_row("Missing", Text(", ".join(sec.missing), style="red"))
|
|
597
|
-
return grid
|
|
598
|
-
|
|
599
|
-
|
|
600
552
|
def _exposure_section(report: ScanReport) -> RenderableType | None:
|
|
601
553
|
exp = report.exposure
|
|
602
554
|
if exp is None:
|
|
@@ -631,17 +583,14 @@ _SECTIONS: tuple[tuple[str, _SectionBuilder], ...] = (
|
|
|
631
583
|
("Network / DNS", _network_section),
|
|
632
584
|
("Registration (WHOIS)", _whois_section),
|
|
633
585
|
("IP intelligence", _ipinfo_section),
|
|
634
|
-
("Infrastructure", _infra_section),
|
|
635
586
|
("TLS / Protocol", _tls_section),
|
|
636
587
|
("Technologies & Software", _tech_section),
|
|
637
588
|
("Vulnerabilities (CVE)", _cve_section),
|
|
638
589
|
("Services & open ports", _services_section),
|
|
639
|
-
("Hosts & OS", _os_section),
|
|
640
590
|
("Default creds / open devices", _creds_section),
|
|
641
591
|
("Secrets & leaks", _secrets_section),
|
|
642
592
|
("Subdomain takeovers", _takeovers_section),
|
|
643
593
|
("Subdomains", _subdomains_section),
|
|
644
|
-
("Security headers", _security_section),
|
|
645
594
|
("Exposure", _exposure_section),
|
|
646
595
|
("Social & contacts", _social_section),
|
|
647
596
|
)
|