web-scanner 2.1.1__tar.gz → 2.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {web_scanner-2.1.1 → web_scanner-2.1.3}/PKG-INFO +18 -13
  2. {web_scanner-2.1.1 → web_scanner-2.1.3}/README.md +17 -12
  3. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/__init__.py +1 -1
  4. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/colors.py +3 -0
  5. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/core/models.py +26 -3
  6. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/core/scanner.py +1 -1
  7. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/modules/__init__.py +5 -2
  8. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/modules/dns.py +28 -21
  9. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/modules/links.py +7 -2
  10. web_scanner-2.1.3/webscanner/modules/sitemap.py +202 -0
  11. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/modules/subdomains.py +25 -4
  12. web_scanner-2.1.3/webscanner/modules/tech.py +52 -0
  13. web_scanner-2.1.3/webscanner/ui/activity.py +326 -0
  14. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/ui/app.py +177 -15
  15. web_scanner-2.1.3/webscanner/ui/app.tcss +172 -0
  16. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/ui/export.py +31 -25
  17. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/ui/tables.py +42 -11
  18. web_scanner-2.1.3/webscanner/ui/widgets.py +294 -0
  19. web_scanner-2.1.1/webscanner/modules/tech.py +0 -43
  20. web_scanner-2.1.1/webscanner/ui/app.tcss +0 -105
  21. web_scanner-2.1.1/webscanner/ui/widgets.py +0 -101
  22. {web_scanner-2.1.1 → web_scanner-2.1.3}/.gitignore +0 -0
  23. {web_scanner-2.1.1 → web_scanner-2.1.3}/LICENSE +0 -0
  24. {web_scanner-2.1.1 → web_scanner-2.1.3}/app.py +0 -0
  25. {web_scanner-2.1.1 → web_scanner-2.1.3}/pyproject.toml +0 -0
  26. {web_scanner-2.1.1 → web_scanner-2.1.3}/requirements.txt +0 -0
  27. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/__main__.py +0 -0
  28. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/cli.py +0 -0
  29. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/core/__init__.py +0 -0
  30. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/core/context.py +0 -0
  31. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/core/module.py +0 -0
  32. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/helpers.py +0 -0
  33. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/modules/headers.py +0 -0
  34. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/modules/security.py +0 -0
  35. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/modules/seo.py +0 -0
  36. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/modules/ssl.py +0 -0
  37. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/modules/whois.py +0 -0
  38. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/net/__init__.py +0 -0
  39. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/net/data/public_suffix_list.dat +0 -0
  40. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/net/http.py +0 -0
  41. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/net/psl.py +0 -0
  42. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/ui/__init__.py +0 -0
  43. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/ui/data/countries.json +0 -0
  44. {web_scanner-2.1.1 → web_scanner-2.1.3}/webscanner/ui/worldmap.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: web-scanner
3
- Version: 2.1.1
3
+ Version: 2.1.3
4
4
  Summary: An async Textual TUI for website reconnaissance — DNS, WHOIS, TLS, security, tech-stack and SEO, with no paid APIs.
5
5
  Project-URL: Homepage, https://github.com/iamramizk/web-scanner
6
6
  Project-URL: Repository, https://github.com/iamramizk/web-scanner
@@ -62,10 +62,11 @@ borders are embedded (Natural Earth), so even the map needs no tile service.
62
62
 
63
63
  ## Features
64
64
 
65
- Nine tabs, scanned concurrently and rendered live as each module finishes:
65
+ Ten tabs, scanned concurrently and rendered live as each module finishes:
66
66
 
67
67
  - **DNS** — A / AAAA / NS / CNAME / SOA / MX / TXT / CAA / DS / DNSKEY records, plus email
68
- authentication folded in: DMARC (`_dmarc`) and DKIM (probes ~40 common selectors).
68
+ authentication folded in: DMARC (`_dmarc`) and DKIM (probes ~40 common selectors, each
69
+ labelled with its email provider).
69
70
  - **Whois** — parsed system `whois`, with rich gTLD and ccTLD field support (registrar,
70
71
  dates, nameservers, per-contact details).
71
72
  - **Subdomains** — discovered natively from TLS certificate SANs and `socket` probes of
@@ -76,10 +77,12 @@ Nine tabs, scanned concurrently and rendered live as each module finishes:
76
77
  X-Frame-Options, …), and blocklist status across public filtering resolvers
77
78
  (AdGuard, CleanBrowsing, Cloudflare, Google, OpenDNS, Quad9).
78
79
  - **Headers** — the full set of HTTP response headers.
79
- - **Tech** — technology-stack detection via [Wappalyzer](https://github.com/tunetheweb/wappalyzer):
80
- name, category, confidence, groups and version.
80
+ - **Tech** — technology-stack detection via [Wappalyzer](https://github.com/tunetheweb/wappalyzer),
81
+ split into one table per technology group, each showing name, category, confidence and version.
81
82
  - **SEO** — page content (title/description with length hints, H1–H3, social links), top
82
83
  keyword n-grams, `robots.txt` and sitemaps, and JSON-LD structured data.
84
+ - **Sitemap** — the site's URLs discovered from its `sitemap.xml` (recursing into nested
85
+ sitemap indexes) and rebuilt into a clickable URL-path tree — no crawling.
83
86
  - **Links** — internal and external links, with their anchor text.
84
87
 
85
88
  Alongside the tabs, two fixed panels:
@@ -87,7 +90,7 @@ Alongside the tabs, two fixed panels:
87
90
  - **Country map** — real country outlines auto-framed around the server's location, drawn
88
91
  with braille characters (`+` / `-` to zoom).
89
92
  - **Server** — online status and response time, IP, geolocation, ISP, AS, hosting provider
90
- and detected tech.
93
+ and detected CMS (name and version, from the Tech scan).
91
94
 
92
95
  ## Installation
93
96
 
@@ -142,10 +145,12 @@ pipx install --force git+https://github.com/iamramizk/web-scanner
142
145
 
143
146
  ### Keys
144
147
 
145
- | Key | Action |
146
- | --------------- | ------------------------------------------------------------------- |
147
- | `←` / `→` `Tab` | Switch tabs |
148
- | `r` | Rescan |
149
- | `s` | Save export every tab to CSV under `output/<domain>_<timestamp>/` |
150
- | `esc` | Edit the domain and scan a new one |
151
- | `q` | Quit |
148
+ | Key | Action |
149
+ | ---------------- | ------------------------------------------------------------------- |
150
+ | `←` / `→` `Tab` | Switch tabs |
151
+ | `+` / `-` | Zoom the country map in / out |
152
+ | `↑` / `↓` `enter`| Navigate the Sitemap tree (`space` expands/collapses all) |
153
+ | `r` | Rescan |
154
+ | `s` | Save — export every tab to CSV under `output/<domain>_<timestamp>/` |
155
+ | `esc` | Edit the domain and scan a new one |
156
+ | `q` | Quit |
@@ -14,10 +14,11 @@ borders are embedded (Natural Earth), so even the map needs no tile service.
14
14
 
15
15
  ## Features
16
16
 
17
- Nine tabs, scanned concurrently and rendered live as each module finishes:
17
+ Ten tabs, scanned concurrently and rendered live as each module finishes:
18
18
 
19
19
  - **DNS** — A / AAAA / NS / CNAME / SOA / MX / TXT / CAA / DS / DNSKEY records, plus email
20
- authentication folded in: DMARC (`_dmarc`) and DKIM (probes ~40 common selectors).
20
+ authentication folded in: DMARC (`_dmarc`) and DKIM (probes ~40 common selectors, each
21
+ labelled with its email provider).
21
22
  - **Whois** — parsed system `whois`, with rich gTLD and ccTLD field support (registrar,
22
23
  dates, nameservers, per-contact details).
23
24
  - **Subdomains** — discovered natively from TLS certificate SANs and `socket` probes of
@@ -28,10 +29,12 @@ Nine tabs, scanned concurrently and rendered live as each module finishes:
28
29
  X-Frame-Options, …), and blocklist status across public filtering resolvers
29
30
  (AdGuard, CleanBrowsing, Cloudflare, Google, OpenDNS, Quad9).
30
31
  - **Headers** — the full set of HTTP response headers.
31
- - **Tech** — technology-stack detection via [Wappalyzer](https://github.com/tunetheweb/wappalyzer):
32
- name, category, confidence, groups and version.
32
+ - **Tech** — technology-stack detection via [Wappalyzer](https://github.com/tunetheweb/wappalyzer),
33
+ split into one table per technology group, each showing name, category, confidence and version.
33
34
  - **SEO** — page content (title/description with length hints, H1–H3, social links), top
34
35
  keyword n-grams, `robots.txt` and sitemaps, and JSON-LD structured data.
36
+ - **Sitemap** — the site's URLs discovered from its `sitemap.xml` (recursing into nested
37
+ sitemap indexes) and rebuilt into a clickable URL-path tree — no crawling.
35
38
  - **Links** — internal and external links, with their anchor text.
36
39
 
37
40
  Alongside the tabs, two fixed panels:
@@ -39,7 +42,7 @@ Alongside the tabs, two fixed panels:
39
42
  - **Country map** — real country outlines auto-framed around the server's location, drawn
40
43
  with braille characters (`+` / `-` to zoom).
41
44
  - **Server** — online status and response time, IP, geolocation, ISP, AS, hosting provider
42
- and detected tech.
45
+ and detected CMS (name and version, from the Tech scan).
43
46
 
44
47
  ## Installation
45
48
 
@@ -94,10 +97,12 @@ pipx install --force git+https://github.com/iamramizk/web-scanner
94
97
 
95
98
  ### Keys
96
99
 
97
- | Key | Action |
98
- | --------------- | ------------------------------------------------------------------- |
99
- | `←` / `→` `Tab` | Switch tabs |
100
- | `r` | Rescan |
101
- | `s` | Save export every tab to CSV under `output/<domain>_<timestamp>/` |
102
- | `esc` | Edit the domain and scan a new one |
103
- | `q` | Quit |
100
+ | Key | Action |
101
+ | ---------------- | ------------------------------------------------------------------- |
102
+ | `←` / `→` `Tab` | Switch tabs |
103
+ | `+` / `-` | Zoom the country map in / out |
104
+ | `↑` / `↓` `enter`| Navigate the Sitemap tree (`space` expands/collapses all) |
105
+ | `r` | Rescan |
106
+ | `s` | Save — export every tab to CSV under `output/<domain>_<timestamp>/` |
107
+ | `esc` | Edit the domain and scan a new one |
108
+ | `q` | Quit |
@@ -1,3 +1,3 @@
1
1
  """web-scanner v2 — async website reconnaissance TUI package."""
2
2
 
3
- __version__ = "2.1.1"
3
+ __version__ = "2.1.3"
@@ -5,3 +5,6 @@ RED = "#FF6C64"
5
5
  #: muted value/secondary text — matches the unselected-tab colour
6
6
  #: ($text-muted = white @ 60% over the dark theme bg).
7
7
  MUTED = "#9D9D9D"
8
+ #: activity-log body text — one step down the white → MUTED ladder, so a log line
9
+ #: reads softer than a heading but stays well clear of the dim MUTED timestamp.
10
+ BODY = "#C6C6C6"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ from dataclasses import dataclass, field
6
6
  from enum import Enum
7
7
  from typing import Any
8
8
 
@@ -62,16 +62,39 @@ class Sections(list):
62
62
  """
63
63
 
64
64
 
65
+ @dataclass(slots=True)
66
+ class TreeNode:
67
+ """One node in a hierarchical result (e.g. the Sitemap tab's URL tree).
68
+
69
+ The module returns a synthetic root ``TreeNode`` whose descendants mirror the
70
+ site's URL-path hierarchy (``/blog`` → ``/blog/post-1`` …). A node is a branch
71
+ (expandable folder) iff it has ``children``; otherwise it's a leaf (a page). The
72
+ UI turns the root into a Textual ``Tree`` widget. ``total`` (set on the root only)
73
+ is the count of page URLs behind the tree, shown in the panel subtitle. ``url`` is
74
+ the full page URL a node stands for (set on leaves), so the UI can make it clickable.
75
+ """
76
+
77
+ label: str
78
+ children: list["TreeNode"] = field(default_factory=list)
79
+ total: int | None = None
80
+ url: str | None = None
81
+
82
+
65
83
  class Grid(list):
66
84
  """A multi-column table result: rows (list of value-lists) + column headers.
67
85
 
68
86
  A ``list`` of rows, so an empty ``Grid`` reports EMPTY via ``_is_empty``. The
69
- first column is treated as each row's primary name.
87
+ first column is treated as each row's primary name. ``widths`` optionally pins
88
+ each column to a fixed character width (else content-fit) — used when several
89
+ Grids share a tab (e.g. Tech's per-group tables) so they all line up.
70
90
  """
71
91
 
72
- def __init__(self, columns: list[str], rows: Any) -> None:
92
+ def __init__(
93
+ self, columns: list[str], rows: Any, widths: list[int] | None = None
94
+ ) -> None:
73
95
  super().__init__(rows)
74
96
  self.columns = list(columns)
97
+ self.widths = list(widths) if widths else None
75
98
 
76
99
  @property
77
100
  def names(self) -> list[str]:
@@ -31,7 +31,7 @@ def _is_empty(data: object) -> bool:
31
31
  return True
32
32
  if isinstance(data, (dict, list, str, tuple, set)):
33
33
  return len(data) == 0
34
- return False
34
+ return False # a returned TreeNode is DONE; modules signal empty by returning None
35
35
 
36
36
 
37
37
  class AsyncScanner:
@@ -1,9 +1,10 @@
1
1
  """Scan module registry.
2
2
 
3
3
  ``all_modules()`` order defines the tab order in the UI:
4
- DNS · Whois · Subdomains · SSL · Security · Headers · Tech · SEO · Links
4
+ DNS · Whois · Subdomains · SSL · Security · Headers · Tech · SEO · Sitemap · Links
5
5
  (Email auth — DMARC/DKIM — is folded into DNS. Security = ports + HTTP headers + blocklists.
6
- SEO = schema + content + keywords. Links = internal + external page links.)
6
+ SEO = schema + content + keywords. Sitemap = the site's published sitemap(s) as a tree.
7
+ Links = internal + external page links.)
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -14,6 +15,7 @@ from .headers import HeadersModule
14
15
  from .links import LinksModule
15
16
  from .security import SecurityModule
16
17
  from .seo import SeoModule
18
+ from .sitemap import SitemapModule
17
19
  from .ssl import SslModule
18
20
  from .subdomains import SubdomainsModule
19
21
  from .tech import TechModule
@@ -31,6 +33,7 @@ def all_modules() -> list[ScanModule]:
31
33
  HeadersModule(),
32
34
  TechModule(),
33
35
  SeoModule(),
36
+ SitemapModule(),
34
37
  LinksModule(),
35
38
  ]
36
39
 
@@ -19,40 +19,44 @@ from ..core.context import ScanContext
19
19
  RECORD_TYPES = ("A", "AAAA", "NS", "CNAME", "SOA", "MX", "TXT", "CAA", "DS", "DNSKEY")
20
20
 
21
21
  # Known DKIM selectors across the major providers/ESPs. DKIM can't be enumerated
22
- # from DNS alone, so we probe this list of well-known selectors.
23
- DKIM_SELECTORS = (
24
- # generic
25
- "default", "dkim", "mail", "selector", "s", "s1", "s2", "key1", "dk",
22
+ # from DNS alone, so we probe this list of well-known selectors. The value is the
23
+ # provider a hit points to (shown next to the selector); an empty string means the
24
+ # selector is generic and doesn't identify a provider on its own.
25
+ DKIM_SELECTORS: dict[str, str] = {
26
+ # generic — the selector name alone doesn't identify a provider
27
+ "default": "", "dkim": "", "mail": "", "selector": "", "s": "",
28
+ "s1": "", "s2": "", "key1": "", "dk": "",
26
29
  # Microsoft 365 / Outlook
27
- "selector1", "selector2",
30
+ "selector1": "Microsoft 365", "selector2": "Microsoft 365",
28
31
  # Google Workspace
29
- "google",
32
+ "google": "Google Workspace",
30
33
  # Amazon SES
31
- "amazonses",
34
+ "amazonses": "Amazon SES",
32
35
  # Zoho
33
- "zoho", "zmail",
36
+ "zoho": "Zoho", "zmail": "Zoho",
34
37
  # ProtonMail
35
- "protonmail", "protonmail2", "protonmail3",
38
+ "protonmail": "Proton Mail", "protonmail2": "Proton Mail", "protonmail3": "Proton Mail",
36
39
  # Fastmail
37
- "fm1", "fm2", "fm3",
40
+ "fm1": "Fastmail", "fm2": "Fastmail", "fm3": "Fastmail",
38
41
  # Mailchimp / Mandrill
39
- "k1", "k2", "k3", "mandrill",
42
+ "k1": "Mailchimp", "k2": "Mailchimp", "k3": "Mailchimp", "mandrill": "Mandrill",
40
43
  # SendGrid
41
- "smtpapi",
44
+ "smtpapi": "SendGrid",
42
45
  # Postmark
43
- "pm",
46
+ "pm": "Postmark",
44
47
  # Mailgun / generic smtp
45
- "smtp", "mg", "mailo",
48
+ "smtp": "", "mg": "Mailgun", "mailo": "",
46
49
  # Campaign Monitor
47
- "cm",
50
+ "cm": "Campaign Monitor",
48
51
  # Apple iCloud
49
- "sig1",
52
+ "sig1": "Apple iCloud",
50
53
  # HubSpot
51
- "hs1", "hs2",
54
+ "hs1": "HubSpot", "hs2": "HubSpot",
52
55
  # misc common
53
- "mxvault", "everlytickey1", "everlytickey2", "titan1", "titan2",
54
- "turbo-smtp", "mailjet", "sendinblue", "klaviyo",
55
- )
56
+ "mxvault": "MXVault", "everlytickey1": "Everlytic", "everlytickey2": "Everlytic",
57
+ "titan1": "Titan", "titan2": "Titan", "turbo-smtp": "TurboSMTP",
58
+ "mailjet": "Mailjet", "sendinblue": "Brevo (Sendinblue)", "klaviyo": "Klaviyo",
59
+ }
56
60
 
57
61
 
58
62
  def _is_dkim(records: list[str]) -> bool:
@@ -92,5 +96,8 @@ class DnsModule(ScanModule):
92
96
  out["DMARC"] = dmarc_records
93
97
  found = [s for s in dkim_hits if s]
94
98
  if found:
95
- out["DKIM"] = found
99
+ out["DKIM"] = [
100
+ f"{s} ({DKIM_SELECTORS[s]})" if DKIM_SELECTORS[s] else s
101
+ for s in found
102
+ ]
96
103
  return out
@@ -18,6 +18,11 @@ from ..core.models import Section, Sections
18
18
 
19
19
  _SKIP_PREFIXES = ("#", "javascript:", "mailto:", "tel:", "data:")
20
20
 
21
+ # Placeholder row shown when a section found nothing. Named so callers can tell
22
+ # "no links" from "one link" — the section is never empty, so len() can't.
23
+ EMPTY_INTERNAL = ("—", "no internal links found")
24
+ EMPTY_EXTERNAL = ("—", "no external links found")
25
+
21
26
 
22
27
  class LinksModule(ScanModule):
23
28
  name = "links"
@@ -31,8 +36,8 @@ class LinksModule(ScanModule):
31
36
  internal, external = await asyncio.to_thread(self._parse, ctx)
32
37
 
33
38
  return Sections([
34
- Section("Internal", internal or [("—", "no internal links found")], ("Link text", "URL")),
35
- Section("External", external or [("—", "no external links found")], ("Link text", "URL")),
39
+ Section("Internal", internal or [EMPTY_INTERNAL], ("Link text", "URL")),
40
+ Section("External", external or [EMPTY_EXTERNAL], ("Link text", "URL")),
36
41
  ])
37
42
 
38
43
  @staticmethod
@@ -0,0 +1,202 @@
1
+ """Sitemap module — the site's URLs as a folder tree built from their paths.
2
+
3
+ This does **not** crawl the site: it discovers the sitemap(s) the site already
4
+ publishes (``Sitemap:`` lines in robots.txt, falling back to ``/sitemap.xml`` and
5
+ ``/sitemap_index.xml``), fetches them (recursing into any nested ``<sitemapindex>``
6
+ files, gunzipping ``.xml.gz``), and collects every page ``<loc>``.
7
+
8
+ Those flat URLs are then rebuilt into a hierarchy keyed by their **path segments**,
9
+ so the tree reads like the site's folder structure — first level is the first slug
10
+ (``/blog``, ``/news``, ``/wp-content`` …), leaves are the final pages — rather than
11
+ mirroring the sitemap *files*. If the sitemaps span more than one host (e.g.
12
+ subdomains), the differing host becomes the top level so URLs aren't merged.
13
+
14
+ Robustness: a ``visited`` set + caps on total sitemap files, collected URLs and
15
+ sitemap-index nesting depth guard against loops and pathologically huge sitemaps.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import gzip
22
+ import xml.etree.ElementTree as ET
23
+ from urllib.parse import urlparse
24
+
25
+ import requests
26
+
27
+ from ..core.module import ScanModule
28
+ from ..core.context import ScanContext
29
+ from ..core.models import TreeNode
30
+ from ..net.http import DEFAULT_HEADERS, TIMEOUT
31
+
32
+ #: total sitemap files fetched across the whole tree (loop / fan-out guard)
33
+ MAX_SITEMAPS = 60
34
+ #: total page URLs collected before we stop (bounds the tree size)
35
+ MAX_URLS = 10000
36
+ #: how deep a chain of nested <sitemapindex> files we follow
37
+ MAX_DEPTH = 6
38
+
39
+
40
+ class SitemapModule(ScanModule):
41
+ name = "sitemap"
42
+ label = "Sitemap"
43
+
44
+ async def run(self, ctx: ScanContext) -> TreeNode | None:
45
+ return await asyncio.to_thread(self._build, ctx)
46
+
47
+ # ---- build (blocking; runs in a thread) -------------------------------
48
+
49
+ def _build(self, ctx: ScanContext) -> TreeNode | None:
50
+ urls = self._collect(ctx.domain)
51
+ if not urls:
52
+ return None # -> EMPTY
53
+ truncated = len(urls) >= MAX_URLS
54
+ return _url_tree(urls, truncated)
55
+
56
+ def _collect(self, domain: str) -> list[str]:
57
+ """Fetch every sitemap (recursing indexes) and return the page URLs found."""
58
+ visited: set[str] = set()
59
+ urls: list[str] = []
60
+ budget = [MAX_SITEMAPS] # mutable counter shared across the recursion
61
+ for sitemap in self._discover(domain):
62
+ self._crawl(sitemap, visited, urls, budget, depth=0)
63
+ if len(urls) >= MAX_URLS:
64
+ break
65
+ seen: set[str] = set() # dedupe, preserve order
66
+ return [u for u in urls if not (u in seen or seen.add(u))]
67
+
68
+ def _crawl(self, url: str, visited: set[str], urls: list[str], budget: list[int], depth: int) -> None:
69
+ if url in visited or budget[0] <= 0 or depth > MAX_DEPTH or len(urls) >= MAX_URLS:
70
+ return
71
+ visited.add(url)
72
+ budget[0] -= 1
73
+
74
+ raw = self._get(url)
75
+ if raw is None:
76
+ return
77
+ kind, locs = _parse_sitemap(raw)
78
+ if kind == "index":
79
+ for loc in locs:
80
+ self._crawl(loc, visited, urls, budget, depth + 1)
81
+ if len(urls) >= MAX_URLS:
82
+ return
83
+ elif kind == "urlset":
84
+ for loc in locs:
85
+ urls.append(loc)
86
+ if len(urls) >= MAX_URLS:
87
+ return
88
+
89
+ # ---- discovery + fetch ------------------------------------------------
90
+
91
+ def _discover(self, domain: str) -> list[str]:
92
+ """Sitemap URLs from robots.txt, else the two conventional defaults."""
93
+ urls = self._robots_sitemaps(domain)
94
+ if not urls:
95
+ base = f"https://{domain}"
96
+ urls = [f"{base}/sitemap.xml", f"{base}/sitemap_index.xml"]
97
+ seen: set[str] = set()
98
+ return [u for u in urls if not (u in seen or seen.add(u))]
99
+
100
+ @staticmethod
101
+ def _robots_sitemaps(domain: str) -> list[str]:
102
+ try:
103
+ resp = requests.get(
104
+ f"https://{domain}/robots.txt",
105
+ headers=DEFAULT_HEADERS, timeout=TIMEOUT, allow_redirects=True,
106
+ )
107
+ if resp.status_code != 200:
108
+ return []
109
+ return [
110
+ ln.split(":", 1)[1].strip()
111
+ for ln in resp.text.splitlines()
112
+ if ln.strip().lower().startswith("sitemap:")
113
+ ]
114
+ except Exception: # noqa: BLE001
115
+ return []
116
+
117
+ @staticmethod
118
+ def _get(url: str) -> bytes | None:
119
+ """Fetch a sitemap; gunzip ``.xml.gz`` / gzip payloads by magic bytes."""
120
+ try:
121
+ resp = requests.get(url, headers=DEFAULT_HEADERS, timeout=TIMEOUT, allow_redirects=True)
122
+ if resp.status_code != 200:
123
+ return None
124
+ raw = resp.content
125
+ if raw[:2] == b"\x1f\x8b": # gzip magic — a served .gz file (not transport enc.)
126
+ raw = gzip.decompress(raw)
127
+ return raw
128
+ except Exception: # noqa: BLE001
129
+ return None
130
+
131
+
132
+ # ---- parsing + tree building ----------------------------------------------
133
+
134
+
135
+ def _parse_sitemap(raw: bytes) -> tuple[str | None, list[str]]:
136
+ """Return ``("index"|"urlset", [loc, …])`` or ``(None, [])`` if unparseable."""
137
+ try:
138
+ root = ET.fromstring(raw)
139
+ except ET.ParseError:
140
+ return None, []
141
+ locs = [
142
+ el.text.strip()
143
+ for el in root.iter()
144
+ if _localname(el.tag) == "loc" and el.text and el.text.strip()
145
+ ]
146
+ kind = "index" if _localname(root.tag) == "sitemapindex" else "urlset"
147
+ return kind, locs
148
+
149
+
150
+ def _localname(tag: str) -> str:
151
+ """Strip the ``{namespace}`` prefix ElementTree keeps on namespaced tags."""
152
+ return tag.rsplit("}", 1)[-1].lower()
153
+
154
+
155
+ def _segments(url: str, include_host: bool) -> list[str]:
156
+ """Path segments for `url` as tree labels: ``/blog``, ``/post`` … An empty path
157
+ (the homepage) yields ``[]`` — it *is* the ``/`` root, not a child of it. When
158
+ `include_host`, a leading host label (no slash) groups multi-host sitemaps. Any
159
+ query is kept on the final leaf."""
160
+ p = urlparse(url)
161
+ parts = [s for s in p.path.split("/") if s]
162
+ segs: list[str] = []
163
+ if include_host and p.netloc:
164
+ segs.append(p.netloc)
165
+ segs += ["/" + s for s in parts]
166
+ if p.query and segs:
167
+ segs[-1] = segs[-1] + "?" + p.query
168
+ return segs
169
+
170
+
171
+ def _url_tree(urls: list[str], truncated: bool) -> TreeNode:
172
+ """Build a path-keyed tree under a visible ``/`` root. Within every level,
173
+ branches (toggleable folders) sort first, then leaf pages — both alphabetical."""
174
+ include_host = len({urlparse(u).netloc for u in urls if urlparse(u).netloc}) > 1
175
+ root = TreeNode(label="/") # the site root / homepage; shown + expanded by the UI
176
+ index: dict[tuple[str, ...], TreeNode] = {(): root}
177
+ for url in sorted(urls):
178
+ parent = root
179
+ node = root
180
+ key: tuple[str, ...] = ()
181
+ for seg in _segments(url, include_host):
182
+ key += (seg,)
183
+ node = index.get(key)
184
+ if node is None:
185
+ node = TreeNode(label=seg)
186
+ index[key] = node
187
+ parent.children.append(node)
188
+ parent = node
189
+ node.url = url # deepest node for this URL (a leaf, unless it later gains children)
190
+ if truncated:
191
+ root.children.append(TreeNode(label=f"… (truncated at {MAX_URLS} URLs)"))
192
+ root.total = len(urls)
193
+ _sort_tree(root)
194
+ return root
195
+
196
+
197
+ def _sort_tree(node: TreeNode) -> None:
198
+ """Recursively order children: branches (have children) before leaves, each
199
+ group alphabetical by label."""
200
+ for child in node.children:
201
+ _sort_tree(child)
202
+ node.children.sort(key=lambda c: (not c.children, c.label.lower()))
@@ -4,7 +4,8 @@ Two native sources:
4
4
  1. Subject Alternative Names on the live TLS certificate (grabbed once in
5
5
  prefetch via ``ssl``/``socket``) — real hostnames the cert is valid for.
6
6
  2. Resolving a curated list of common subdomains via ``socket`` and keeping the
7
- ones that exist.
7
+ ones that exist. Skipped when the domain has wildcard DNS (a random label
8
+ resolves), since every probe would otherwise be a false positive.
8
9
 
9
10
  Less exhaustive than a Certificate Transparency search, but instant and
10
11
  dependency-free. Swap in a CT/API source later if broader coverage is needed.
@@ -13,6 +14,7 @@ dependency-free. Swap in a CT/API source later if broader coverage is needed.
13
14
  from __future__ import annotations
14
15
 
15
16
  import asyncio
17
+ import secrets
16
18
  import socket
17
19
 
18
20
  from ..core.module import ScanModule
@@ -22,6 +24,20 @@ COMMON_SUBDOMAINS = (
22
24
  "www", "mail", "webmail", "smtp", "imap", "pop", "ftp", "cpanel", "webdisk",
23
25
  "ns1", "ns2", "mx", "api", "dev", "staging", "blog", "shop", "admin",
24
26
  "portal", "vpn", "cdn", "m",
27
+ # SaaS & Authentication
28
+ "app", "login", "secure", "auth", "account", "dashboard",
29
+ # Support & Resources
30
+ "help", "support", "status", "docs", "download",
31
+ # Infrastructure & Internal
32
+ "test", "demo", "qa", "git", "internal", "autodiscover",
33
+ # Mail & DNS
34
+ "ns3", "mx1", "mx2", "email", "autoconfig", "owa", "exchange",
35
+ # Assets & CDN
36
+ "static", "assets", "media", "web",
37
+ # Environments & Dev infra
38
+ "beta", "sandbox", "gitlab", "wiki",
39
+ # Community & Customer
40
+ "store", "forum", "my", "mobile", "remote",
25
41
  )
26
42
 
27
43
 
@@ -41,7 +57,7 @@ class SubdomainsModule(ScanModule):
41
57
  if host == ctx.domain or host.endswith("." + ctx.domain):
42
58
  found.add(host)
43
59
 
44
- # 2. resolve common subdomains concurrently
60
+ # 2. resolve common subdomains concurrently (skip if wildcard DNS)
45
61
  async def probe(sub: str) -> str | None:
46
62
  host = f"{sub}.{ctx.domain}"
47
63
  try:
@@ -50,7 +66,12 @@ class SubdomainsModule(ScanModule):
50
66
  except OSError:
51
67
  return None
52
68
 
53
- resolved = await asyncio.gather(*(probe(s) for s in COMMON_SUBDOMAINS))
54
- found.update(host for host in resolved if host)
69
+ # A random label should not exist; if it resolves the domain has
70
+ # wildcard DNS and the brute-force list would be all false positives.
71
+ wildcard = await probe(f"wildcard-probe-{secrets.token_hex(6)}")
72
+ if wildcard is None:
73
+ resolved = await asyncio.gather(*(probe(s) for s in COMMON_SUBDOMAINS))
74
+ found.update(host for host in resolved if host)
75
+
55
76
  found.discard(ctx.domain)
56
77
  return sorted(found)
@@ -0,0 +1,52 @@
1
+ """Technology detection via Wappalyzer.
2
+
3
+ Uses ``analyze(..., scan_type="fast")`` — a pure-HTTP path (no headless browser) —
4
+ which returns per-technology version, confidence, categories and groups. Rendered as
5
+ one :class:`Grid` per *group* (Wappalyzer's high-level bucket, e.g. "Web servers",
6
+ "Analytics"), stacked as titled :class:`Sections`. A technology in several groups
7
+ appears in each of their tables.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+
14
+ from wappalyzer import analyze
15
+
16
+ from ..core.module import ScanModule
17
+ from ..core.context import ScanContext
18
+ from ..core.models import Grid, Section, Sections
19
+
20
+ COLUMNS = ["Name", "Category", "Confidence", "Version"]
21
+ #: fixed column widths so every per-group table on the tab lines up identically
22
+ WIDTHS = [26, 22, 12, 12]
23
+ #: bucket for technologies Wappalyzer reports without any group
24
+ UNGROUPED = "Other"
25
+
26
+
27
+ class TechModule(ScanModule):
28
+ name = "tech"
29
+ label = "Tech"
30
+
31
+ async def run(self, ctx: ScanContext) -> Sections:
32
+ def detect() -> Sections:
33
+ results = analyze(url=ctx.url, scan_type="fast", timeout=30)
34
+ techs = results.get(ctx.url) or next(iter(results.values()), {})
35
+ # group name -> rows; a tech lands in every group it declares
36
+ groups: dict[str, list[list[str]]] = {}
37
+ for name, info in techs.items():
38
+ row = [
39
+ name,
40
+ ", ".join(info.get("categories") or []) or "-",
41
+ f"{info.get('confidence', 0)}%",
42
+ info.get("version") or "-",
43
+ ]
44
+ for group in info.get("groups") or [UNGROUPED]:
45
+ groups.setdefault(group, []).append(row)
46
+ sections = Sections()
47
+ for group in sorted(groups, key=str.lower): # tables A→Z by group
48
+ rows = sorted(groups[group], key=lambda r: r[0].lower()) # names A→Z
49
+ sections.append(Section(group, Grid(COLUMNS, rows, widths=WIDTHS)))
50
+ return sections
51
+
52
+ return await asyncio.to_thread(detect)