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.
Files changed (77) hide show
  1. {stackscan-2.5.0 → stackscan-2.6.0}/PKG-INFO +1 -1
  2. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/__init__.py +1 -1
  3. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/osdetect.py +1 -3
  4. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/tech.py +16 -55
  5. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/cli.py +8 -8
  6. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/export.py +13 -44
  7. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/dns.py +5 -1
  8. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/fingerprint.py +1 -7
  9. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/whois.py +39 -1
  10. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/render.py +26 -77
  11. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/scan.py +46 -37
  12. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/scanners/secrets.py +7 -14
  13. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/scanners/takeover.py +3 -4
  14. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/theme.py +3 -2
  15. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/types/output.py +8 -0
  16. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_brute.py +1 -3
  17. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_cli_helpers.py +3 -0
  18. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_dns_records.py +17 -0
  19. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_export.py +49 -0
  20. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_ports.py +1 -3
  21. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_scan.py +19 -2
  22. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_services.py +2 -6
  23. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_whois.py +16 -0
  24. {stackscan-2.5.0 → stackscan-2.6.0}/.gitignore +0 -0
  25. {stackscan-2.5.0 → stackscan-2.6.0}/LICENSE +0 -0
  26. {stackscan-2.5.0 → stackscan-2.6.0}/README.md +0 -0
  27. {stackscan-2.5.0 → stackscan-2.6.0}/pyproject.toml +0 -0
  28. {stackscan-2.5.0 → stackscan-2.6.0}/scripts/build_cve_db.py +0 -0
  29. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/__main__.py +0 -0
  30. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/__init__.py +0 -0
  31. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/creds.py +0 -0
  32. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/cve.py +0 -0
  33. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/exposure.py +0 -0
  34. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/generic.py +0 -0
  35. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/infra.py +0 -0
  36. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/security.py +0 -0
  37. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/services.py +0 -0
  38. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/social.py +0 -0
  39. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/analyzers/vibe.py +0 -0
  40. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/config/__init__.py +0 -0
  41. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/config/sigdb_loader.py +0 -0
  42. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/config/sources.py +0 -0
  43. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/core/__init__.py +0 -0
  44. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/core/core.py +0 -0
  45. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/data/builtin.sigdb +0 -0
  46. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/data/cve.json.gz +0 -0
  47. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/data/reekeer-logo.png +0 -0
  48. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/data/subdomains.txt +0 -0
  49. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/__init__.py +0 -0
  50. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/geo.py +0 -0
  51. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/ipinfo.py +0 -0
  52. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/ports.py +0 -0
  53. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/resolver.py +0 -0
  54. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/subdomains.py +0 -0
  55. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/tld.py +0 -0
  56. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/net/tls.py +0 -0
  57. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/scanners/__init__.py +0 -0
  58. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/scanners/isp_blocked.py +0 -0
  59. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/types/__init__.py +0 -0
  60. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/types/models.py +0 -0
  61. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/utils/__init__.py +0 -0
  62. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/utils/paths.py +0 -0
  63. {stackscan-2.5.0 → stackscan-2.6.0}/src/stackscan/utils/urls.py +0 -0
  64. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_builtin_sigdb.py +0 -0
  65. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_creds.py +0 -0
  66. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_cve.py +0 -0
  67. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_extra.py +0 -0
  68. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_infra.py +0 -0
  69. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_osdetect.py +0 -0
  70. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_scanners.py +0 -0
  71. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_social.py +0 -0
  72. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_sources.py +0 -0
  73. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_subdomains.py +0 -0
  74. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_tech.py +0 -0
  75. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_theme.py +0 -0
  76. {stackscan-2.5.0 → stackscan-2.6.0}/tests/test_vhost.py +0 -0
  77. {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.5.0
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
@@ -2,4 +2,4 @@ from .core import StackscanSession
2
2
  from .utils import normalize_url
3
3
 
4
4
  __all__ = ["StackscanSession", "__version__", "normalize_url"]
5
- __version__ = "2.5.0"
5
+ __version__ = "2.6.0"
@@ -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
- "container",
46
- "flex",
47
- "grid",
48
- "block",
49
- "inline",
50
- "inline-block",
51
- "hidden",
52
- "table",
53
- "table-cell",
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(acc, matcher.match_search({"framework": token}), f"framework:{token}")
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"[~] {self._target} · {message}",
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"[~] {self._target} · {message}",
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"[~] {self._target} · {message}",
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 >= 2 or (args.verbose == 0 and total_targets == 1)
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(task_id, completed=target_total, description=f"[+] {target}")
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 < 2
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
- rows = ""
218
- if whois.get("registrar"):
219
- rows += _kv("Registrar", _e(whois["registrar"]))
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
- _RECORD_TYPES = ("A", "AAAA", "CNAME", "MX", "NS", "TXT", "SOA", "CAA")
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
- registrar = _registrar_name(_find_role(entities, "registrar"))
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, summarize_edge
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
- if whois.registrar:
167
- grid.add_row("Registrar", whois.registrar)
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
- key = (name.lower(), version, host)
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
  )