web-scanner 2.1.2__tar.gz → 2.1.4__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.2 → web_scanner-2.1.4}/PKG-INFO +43 -14
  2. {web_scanner-2.1.2 → web_scanner-2.1.4}/README.md +40 -13
  3. {web_scanner-2.1.2 → web_scanner-2.1.4}/pyproject.toml +5 -0
  4. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/__init__.py +1 -1
  5. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/colors.py +3 -0
  6. web_scanner-2.1.4/webscanner/core/context.py +72 -0
  7. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/core/scanner.py +1 -1
  8. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/links.py +7 -2
  9. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/seo.py +7 -5
  10. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/sitemap.py +22 -16
  11. web_scanner-2.1.4/webscanner/modules/tech.py +119 -0
  12. web_scanner-2.1.4/webscanner/net/agents.py +153 -0
  13. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/net/http.py +26 -9
  14. web_scanner-2.1.4/webscanner/ui/activity.py +352 -0
  15. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/app.py +104 -10
  16. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/app.tcss +35 -1
  17. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/export.py +1 -19
  18. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/tables.py +10 -0
  19. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/widgets.py +116 -2
  20. web_scanner-2.1.2/webscanner/core/context.py +0 -41
  21. web_scanner-2.1.2/webscanner/modules/tech.py +0 -52
  22. {web_scanner-2.1.2 → web_scanner-2.1.4}/.gitignore +0 -0
  23. {web_scanner-2.1.2 → web_scanner-2.1.4}/LICENSE +0 -0
  24. {web_scanner-2.1.2 → web_scanner-2.1.4}/app.py +0 -0
  25. {web_scanner-2.1.2 → web_scanner-2.1.4}/requirements.txt +0 -0
  26. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/__main__.py +0 -0
  27. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/cli.py +0 -0
  28. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/core/__init__.py +0 -0
  29. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/core/models.py +0 -0
  30. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/core/module.py +0 -0
  31. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/helpers.py +0 -0
  32. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/__init__.py +0 -0
  33. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/dns.py +0 -0
  34. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/headers.py +0 -0
  35. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/security.py +0 -0
  36. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/ssl.py +0 -0
  37. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/subdomains.py +0 -0
  38. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/whois.py +0 -0
  39. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/net/__init__.py +0 -0
  40. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/net/data/public_suffix_list.dat +0 -0
  41. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/net/psl.py +0 -0
  42. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/__init__.py +0 -0
  43. {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/data/countries.json +0 -0
  44. {web_scanner-2.1.2 → web_scanner-2.1.4}/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.2
3
+ Version: 2.1.4
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
@@ -44,29 +44,49 @@ Requires-Dist: requests>=2.28.2
44
44
  Requires-Dist: rich>=15.0.0
45
45
  Requires-Dist: textual>=8.2.8
46
46
  Requires-Dist: wappalyzer>=2.0.2
47
+ Provides-Extra: test
48
+ Requires-Dist: pytest>=8.0; extra == 'test'
47
49
  Description-Content-Type: text/markdown
48
50
 
51
+ <div align="center">
52
+
49
53
  # WebScanner
50
54
 
51
- An async [Textual](https://textual.textualize.io/) TUI for website reconnaissance. Point it
52
- at a domain and it concurrently gathers DNS, WHOIS, TLS, security, tech-stack and SEO
53
- intelligence, then lays it out across tabs — with a live country map and a server-status
54
- panel that stay pinned in place.
55
+ _An async Textual TUI for website reconnaissance DNS, WHOIS, TLS, security, tech-stack and SEO, with no paid APIs._
56
+
57
+ [![Python](https://img.shields.io/badge/Python-3.11%2B-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/)
58
+ [![PyPI Downloads](https://img.shields.io/pypi/dm/web-scanner?style=for-the-badge&logo=pypi&logoColor=white&color=006DAD)](https://pypi.org/project/web-scanner/)
59
+ [![Release](https://img.shields.io/github/v/release/iamramizk/web-scanner?style=for-the-badge&logo=github&color=2EA44F)](https://github.com/iamramizk/web-scanner/releases/latest)
60
+
61
+ </div>
62
+
63
+ Point it at a domain and it concurrently gathers DNS, WHOIS, TLS, security, tech-stack and
64
+ SEO intelligence, then lays it out across tabs — with a live activity log, a country map and
65
+ a server-status panel that stay pinned in place.
55
66
 
56
67
  **No paid APIs.** Everything runs off free, public endpoints and the standard library:
57
68
  `ip-api.com` for geolocation, Cloudflare/Google DoH and `dig` against public filtering
58
69
  resolvers for blocklist checks, the system `whois`, and stdlib `ssl`/`socket`. Country
59
70
  borders are embedded (Natural Earth), so even the map needs no tile service.
60
71
 
61
- ![WebScanner](https://raw.githubusercontent.com/iamramizk/web-scanner/main/.github/screenshot-v2.png)
72
+ ![WebScanner](https://raw.githubusercontent.com/iamramizk/web-scanner/main/.github/screenshot-v2.1.png)
73
+
74
+ ## Contents
75
+
76
+ - [Features](#features)
77
+ - [Installation](#installation)
78
+ - [From source (development)](#from-source-development)
79
+ - [Usage](#usage)
80
+ - [Updating](#updating)
81
+ - [Keys](#keys)
62
82
 
63
83
  ## Features
64
84
 
65
85
  Ten tabs, scanned concurrently and rendered live as each module finishes:
66
86
 
67
87
  - **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, each
69
- labelled with its email provider).
88
+ authentication folded in: DMARC (`_dmarc`) and DKIM (probes ~40 known selectors, each hit
89
+ labelled with the email provider it points to, where the selector identifies one).
70
90
  - **Whois** — parsed system `whois`, with rich gTLD and ccTLD field support (registrar,
71
91
  dates, nameservers, per-contact details).
72
92
  - **Subdomains** — discovered natively from TLS certificate SANs and `socket` probes of
@@ -78,19 +98,28 @@ Ten tabs, scanned concurrently and rendered live as each module finishes:
78
98
  (AdGuard, CleanBrowsing, Cloudflare, Google, OpenDNS, Quad9).
79
99
  - **Headers** — the full set of HTTP response headers.
80
100
  - **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.
101
+ listing each technology with its category, confidence, groups and version.
82
102
  - **SEO** — page content (title/description with length hints, H1–H3, social links), top
83
103
  keyword n-grams, `robots.txt` and sitemaps, and JSON-LD structured data.
84
104
  - **Sitemap** — the site's URLs discovered from its `sitemap.xml` (recursing into nested
85
105
  sitemap indexes) and rebuilt into a clickable URL-path tree — no crawling.
86
106
  - **Links** — internal and external links, with their anchor text.
87
107
 
88
- Alongside the tabs, two fixed panels:
108
+ Alongside the tabs, three fixed panels:
89
109
 
110
+ - **Activity Log** — a running narration of the scan: what each module found, what came back
111
+ empty and what broke, so every headline result is visible without switching tabs.
90
112
  - **Country map** — real country outlines auto-framed around the server's location, drawn
91
113
  with braille characters (`+` / `-` to zoom).
92
- - **Server** — online status and response time, IP, geolocation, ISP, AS, hosting provider
93
- and detected CMS (name and version, from the Tech scan).
114
+ - **Server** — online status and response time, the final URL after redirects, IP,
115
+ geolocation, ISP, AS, hosting provider and detected CMS (name and version).
116
+
117
+ Requests to the site being scanned wear a **coherent desktop-Chrome identity** — a real
118
+ User-Agent plus the headers Chrome actually sends beside it — picked once per scan and
119
+ reused for every request, and reported in the Activity Log. This is about getting the real
120
+ page back rather than a WAF block page, and it only defeats naive User-Agent filtering: the
121
+ TLS and HTTP/2 fingerprints are still those of `requests`, so enterprise bot management sees
122
+ straight through it. Third-party lookups (ip-api, DoH) keep an honest scanner User-Agent.
94
123
 
95
124
  ## Installation
96
125
 
@@ -143,7 +172,7 @@ so reinstall from source instead:
143
172
  pipx install --force git+https://github.com/iamramizk/web-scanner
144
173
  ```
145
174
 
146
- ### Keys
175
+ ## Keys
147
176
 
148
177
  | Key | Action |
149
178
  | ---------------- | ------------------------------------------------------------------- |
@@ -151,6 +180,6 @@ pipx install --force git+https://github.com/iamramizk/web-scanner
151
180
  | `+` / `-` | Zoom the country map in / out |
152
181
  | `↑` / `↓` `enter`| Navigate the Sitemap tree (`space` expands/collapses all) |
153
182
  | `r` | Rescan |
154
- | `s` | Save — export every tab to CSV under `output/<domain>_<timestamp>/` |
183
+ | `s` | Save — export every tab to CSV under `<domain>_<timestamp>/` |
155
184
  | `esc` | Edit the domain and scan a new one |
156
185
  | `q` | Quit |
@@ -1,24 +1,42 @@
1
+ <div align="center">
2
+
1
3
  # WebScanner
2
4
 
3
- An async [Textual](https://textual.textualize.io/) TUI for website reconnaissance. Point it
4
- at a domain and it concurrently gathers DNS, WHOIS, TLS, security, tech-stack and SEO
5
- intelligence, then lays it out across tabs — with a live country map and a server-status
6
- panel that stay pinned in place.
5
+ _An async Textual TUI for website reconnaissance DNS, WHOIS, TLS, security, tech-stack and SEO, with no paid APIs._
6
+
7
+ [![Python](https://img.shields.io/badge/Python-3.11%2B-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/downloads/)
8
+ [![PyPI Downloads](https://img.shields.io/pypi/dm/web-scanner?style=for-the-badge&logo=pypi&logoColor=white&color=006DAD)](https://pypi.org/project/web-scanner/)
9
+ [![Release](https://img.shields.io/github/v/release/iamramizk/web-scanner?style=for-the-badge&logo=github&color=2EA44F)](https://github.com/iamramizk/web-scanner/releases/latest)
10
+
11
+ </div>
12
+
13
+ Point it at a domain and it concurrently gathers DNS, WHOIS, TLS, security, tech-stack and
14
+ SEO intelligence, then lays it out across tabs — with a live activity log, a country map and
15
+ a server-status panel that stay pinned in place.
7
16
 
8
17
  **No paid APIs.** Everything runs off free, public endpoints and the standard library:
9
18
  `ip-api.com` for geolocation, Cloudflare/Google DoH and `dig` against public filtering
10
19
  resolvers for blocklist checks, the system `whois`, and stdlib `ssl`/`socket`. Country
11
20
  borders are embedded (Natural Earth), so even the map needs no tile service.
12
21
 
13
- ![WebScanner](https://raw.githubusercontent.com/iamramizk/web-scanner/main/.github/screenshot-v2.png)
22
+ ![WebScanner](https://raw.githubusercontent.com/iamramizk/web-scanner/main/.github/screenshot-v2.1.png)
23
+
24
+ ## Contents
25
+
26
+ - [Features](#features)
27
+ - [Installation](#installation)
28
+ - [From source (development)](#from-source-development)
29
+ - [Usage](#usage)
30
+ - [Updating](#updating)
31
+ - [Keys](#keys)
14
32
 
15
33
  ## Features
16
34
 
17
35
  Ten tabs, scanned concurrently and rendered live as each module finishes:
18
36
 
19
37
  - **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, each
21
- labelled with its email provider).
38
+ authentication folded in: DMARC (`_dmarc`) and DKIM (probes ~40 known selectors, each hit
39
+ labelled with the email provider it points to, where the selector identifies one).
22
40
  - **Whois** — parsed system `whois`, with rich gTLD and ccTLD field support (registrar,
23
41
  dates, nameservers, per-contact details).
24
42
  - **Subdomains** — discovered natively from TLS certificate SANs and `socket` probes of
@@ -30,19 +48,28 @@ Ten tabs, scanned concurrently and rendered live as each module finishes:
30
48
  (AdGuard, CleanBrowsing, Cloudflare, Google, OpenDNS, Quad9).
31
49
  - **Headers** — the full set of HTTP response headers.
32
50
  - **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.
51
+ listing each technology with its category, confidence, groups and version.
34
52
  - **SEO** — page content (title/description with length hints, H1–H3, social links), top
35
53
  keyword n-grams, `robots.txt` and sitemaps, and JSON-LD structured data.
36
54
  - **Sitemap** — the site's URLs discovered from its `sitemap.xml` (recursing into nested
37
55
  sitemap indexes) and rebuilt into a clickable URL-path tree — no crawling.
38
56
  - **Links** — internal and external links, with their anchor text.
39
57
 
40
- Alongside the tabs, two fixed panels:
58
+ Alongside the tabs, three fixed panels:
41
59
 
60
+ - **Activity Log** — a running narration of the scan: what each module found, what came back
61
+ empty and what broke, so every headline result is visible without switching tabs.
42
62
  - **Country map** — real country outlines auto-framed around the server's location, drawn
43
63
  with braille characters (`+` / `-` to zoom).
44
- - **Server** — online status and response time, IP, geolocation, ISP, AS, hosting provider
45
- and detected CMS (name and version, from the Tech scan).
64
+ - **Server** — online status and response time, the final URL after redirects, IP,
65
+ geolocation, ISP, AS, hosting provider and detected CMS (name and version).
66
+
67
+ Requests to the site being scanned wear a **coherent desktop-Chrome identity** — a real
68
+ User-Agent plus the headers Chrome actually sends beside it — picked once per scan and
69
+ reused for every request, and reported in the Activity Log. This is about getting the real
70
+ page back rather than a WAF block page, and it only defeats naive User-Agent filtering: the
71
+ TLS and HTTP/2 fingerprints are still those of `requests`, so enterprise bot management sees
72
+ straight through it. Third-party lookups (ip-api, DoH) keep an honest scanner User-Agent.
46
73
 
47
74
  ## Installation
48
75
 
@@ -95,7 +122,7 @@ so reinstall from source instead:
95
122
  pipx install --force git+https://github.com/iamramizk/web-scanner
96
123
  ```
97
124
 
98
- ### Keys
125
+ ## Keys
99
126
 
100
127
  | Key | Action |
101
128
  | ---------------- | ------------------------------------------------------------------- |
@@ -103,6 +130,6 @@ pipx install --force git+https://github.com/iamramizk/web-scanner
103
130
  | `+` / `-` | Zoom the country map in / out |
104
131
  | `↑` / `↓` `enter`| Navigate the Sitemap tree (`space` expands/collapses all) |
105
132
  | `r` | Rescan |
106
- | `s` | Save — export every tab to CSV under `output/<domain>_<timestamp>/` |
133
+ | `s` | Save — export every tab to CSV under `<domain>_<timestamp>/` |
107
134
  | `esc` | Edit the domain and scan a new one |
108
135
  | `q` | Quit |
@@ -32,6 +32,11 @@ dependencies = [
32
32
  "beautifulsoup4>=4.12.2",
33
33
  ]
34
34
 
35
+ [project.optional-dependencies]
36
+ # Test-only; not a runtime dep, so requirements.txt does not mirror it.
37
+ # pip install -e ".[test]" && python -m pytest
38
+ test = ["pytest>=8.0"]
39
+
35
40
  [project.urls]
36
41
  Homepage = "https://github.com/iamramizk/web-scanner"
37
42
  Repository = "https://github.com/iamramizk/web-scanner"
@@ -1,3 +1,3 @@
1
1
  """web-scanner v2 — async website reconnaissance TUI package."""
2
2
 
3
- __version__ = "2.1.2"
3
+ __version__ = "2.1.4"
@@ -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"
@@ -0,0 +1,72 @@
1
+ """Shared per-scan context passed to every module.
2
+
3
+ The orchestrator's prefetch phase populates the shared network fields (one DNS
4
+ resolve, one HTTP GET, one TLS handshake, one geo lookup) so individual modules
5
+ reuse them instead of refetching.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+ from urllib.parse import urlparse
13
+
14
+ from .. import helpers
15
+ from ..net.agents import Profile, random_profile
16
+
17
+
18
+ @dataclass
19
+ class ScanContext:
20
+ # inputs
21
+ domain: str # bare host, e.g. "example.com"
22
+ url: str # full url, e.g. "https://example.com"
23
+
24
+ #: The browser identity every target-facing request wears. Chosen once per scan
25
+ #: and shared, so the site sees one consistent visitor rather than a different
26
+ #: browser per request — see net/agents.py. Not used for ip-api/DoH.
27
+ profile: Profile = field(default_factory=random_profile)
28
+
29
+ # shared prefetch results (filled by AsyncScanner.prefetch)
30
+ ip: str | None = None
31
+ html: str | None = None
32
+ headers: dict[str, str] = field(default_factory=dict)
33
+ status_code: int | None = None
34
+ response_time_ms: float | None = None
35
+ final_url: str | None = None
36
+ redirect_status: str | None = None # e.g. "301 Moved Permanently" for cross-domain redirects
37
+ tls_cert: dict[str, Any] | None = None
38
+ geo: dict[str, Any] | None = None
39
+ fetch_error: str | None = None
40
+
41
+ @classmethod
42
+ def from_target(cls, target: str) -> "ScanContext":
43
+ domain, url = helpers.normalise(target)
44
+ return cls(domain=domain, url=url)
45
+
46
+ @property
47
+ def base(self) -> str:
48
+ """Origin to build target URLs from: ``<scheme>://<domain>``.
49
+
50
+ The scheme is the one prefetch actually *reached*, not the one we asked for.
51
+ ``helpers.normalise()`` always hands us ``https://`` regardless of what the
52
+ user typed, so :attr:`url` is not a scheme signal; ``http.fetch()`` retries
53
+ over ``http://`` when https won't connect, and :attr:`final_url` records
54
+ where it landed. An http-only site that builds its URLs from :attr:`url`
55
+ silently gets nothing back (no robots.txt, no sitemap, no tech) while the
56
+ rest of the scan succeeds.
57
+
58
+ The host comes from :attr:`domain`, *not* from ``final_url``: a bare→www
59
+ redirect would otherwise pin us to ``www.example.com``, and requests we
60
+ build follow redirects there anyway.
61
+
62
+ **Only valid after prefetch.** Before it (``final_url`` is None) this falls
63
+ back to https — which is also the right answer when the fetch failed
64
+ outright, since nothing else will connect either. ``prefetch()`` itself must
65
+ never read this: it is what determines the scheme.
66
+ """
67
+ scheme = urlparse(self.final_url).scheme if self.final_url else ""
68
+ return f"{scheme or 'https'}://{self.domain}"
69
+
70
+ @property
71
+ def online(self) -> bool:
72
+ return self.status_code is not None and self.fetch_error is None
@@ -56,7 +56,7 @@ class AsyncScanner:
56
56
 
57
57
  async def _fetch() -> None:
58
58
  try:
59
- r = await asyncio.to_thread(http.fetch, self.ctx.url)
59
+ r = await asyncio.to_thread(http.fetch, self.ctx.url, self.ctx.profile)
60
60
  self.ctx.status_code = r["status_code"]
61
61
  self.ctx.headers = r["headers"]
62
62
  self.ctx.html = r["html"]
@@ -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
@@ -26,7 +26,8 @@ from ..colors import GREEN, RED, MUTED
26
26
  from ..core.module import ScanModule
27
27
  from ..core.context import ScanContext
28
28
  from ..core.models import Section, Sections
29
- from ..net.http import DEFAULT_HEADERS, TIMEOUT
29
+ from ..net.agents import Profile
30
+ from ..net.http import TIMEOUT
30
31
 
31
32
  TITLE_RANGE = (30, 60)
32
33
  DESC_RANGE = (70, 160)
@@ -103,7 +104,7 @@ class SeoModule(ScanModule):
103
104
  label = "SEO"
104
105
 
105
106
  async def run(self, ctx: ScanContext) -> Sections:
106
- robots_coro = asyncio.to_thread(self._fetch_robots, ctx.domain)
107
+ robots_coro = asyncio.to_thread(self._fetch_robots, ctx.base, ctx.profile)
107
108
  if not ctx.html:
108
109
  robots = await robots_coro
109
110
  note = {"note": "no page content"}
@@ -169,11 +170,12 @@ class SeoModule(ScanModule):
169
170
  return schema, content, keywords
170
171
 
171
172
  @staticmethod
172
- def _fetch_robots(domain: str) -> dict[str, object]:
173
+ def _fetch_robots(base: str, profile: Profile) -> dict[str, object]:
174
+ url = f"{base}/robots.txt"
173
175
  try:
174
176
  resp = requests.get(
175
- f"https://{domain}/robots.txt",
176
- headers=DEFAULT_HEADERS, timeout=TIMEOUT, allow_redirects=True,
177
+ url,
178
+ headers=profile.headers(url), timeout=TIMEOUT, allow_redirects=True,
177
179
  )
178
180
  text = resp.text.strip()
179
181
  ctype = resp.headers.get("content-type", "").lower()
@@ -27,7 +27,8 @@ import requests
27
27
  from ..core.module import ScanModule
28
28
  from ..core.context import ScanContext
29
29
  from ..core.models import TreeNode
30
- from ..net.http import DEFAULT_HEADERS, TIMEOUT
30
+ from ..net.agents import Profile
31
+ from ..net.http import TIMEOUT
31
32
 
32
33
  #: total sitemap files fetched across the whole tree (loop / fan-out guard)
33
34
  MAX_SITEMAPS = 60
@@ -47,37 +48,40 @@ class SitemapModule(ScanModule):
47
48
  # ---- build (blocking; runs in a thread) -------------------------------
48
49
 
49
50
  def _build(self, ctx: ScanContext) -> TreeNode | None:
50
- urls = self._collect(ctx.domain)
51
+ urls = self._collect(ctx.base, ctx.profile)
51
52
  if not urls:
52
53
  return None # -> EMPTY
53
54
  truncated = len(urls) >= MAX_URLS
54
55
  return _url_tree(urls, truncated)
55
56
 
56
- def _collect(self, domain: str) -> list[str]:
57
+ def _collect(self, base: str, profile: Profile) -> list[str]:
57
58
  """Fetch every sitemap (recursing indexes) and return the page URLs found."""
58
59
  visited: set[str] = set()
59
60
  urls: list[str] = []
60
61
  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)
62
+ for sitemap in self._discover(base, profile):
63
+ self._crawl(sitemap, profile, visited, urls, budget, depth=0)
63
64
  if len(urls) >= MAX_URLS:
64
65
  break
65
66
  seen: set[str] = set() # dedupe, preserve order
66
67
  return [u for u in urls if not (u in seen or seen.add(u))]
67
68
 
68
- def _crawl(self, url: str, visited: set[str], urls: list[str], budget: list[int], depth: int) -> None:
69
+ def _crawl(
70
+ self, url: str, profile: Profile, visited: set[str], urls: list[str],
71
+ budget: list[int], depth: int,
72
+ ) -> None:
69
73
  if url in visited or budget[0] <= 0 or depth > MAX_DEPTH or len(urls) >= MAX_URLS:
70
74
  return
71
75
  visited.add(url)
72
76
  budget[0] -= 1
73
77
 
74
- raw = self._get(url)
78
+ raw = self._get(url, profile)
75
79
  if raw is None:
76
80
  return
77
81
  kind, locs = _parse_sitemap(raw)
78
82
  if kind == "index":
79
83
  for loc in locs:
80
- self._crawl(loc, visited, urls, budget, depth + 1)
84
+ self._crawl(loc, profile, visited, urls, budget, depth + 1)
81
85
  if len(urls) >= MAX_URLS:
82
86
  return
83
87
  elif kind == "urlset":
@@ -88,21 +92,21 @@ class SitemapModule(ScanModule):
88
92
 
89
93
  # ---- discovery + fetch ------------------------------------------------
90
94
 
91
- def _discover(self, domain: str) -> list[str]:
95
+ def _discover(self, base: str, profile: Profile) -> list[str]:
92
96
  """Sitemap URLs from robots.txt, else the two conventional defaults."""
93
- urls = self._robots_sitemaps(domain)
97
+ urls = self._robots_sitemaps(base, profile)
94
98
  if not urls:
95
- base = f"https://{domain}"
96
99
  urls = [f"{base}/sitemap.xml", f"{base}/sitemap_index.xml"]
97
100
  seen: set[str] = set()
98
101
  return [u for u in urls if not (u in seen or seen.add(u))]
99
102
 
100
103
  @staticmethod
101
- def _robots_sitemaps(domain: str) -> list[str]:
104
+ def _robots_sitemaps(base: str, profile: Profile) -> list[str]:
105
+ url = f"{base}/robots.txt"
102
106
  try:
103
107
  resp = requests.get(
104
- f"https://{domain}/robots.txt",
105
- headers=DEFAULT_HEADERS, timeout=TIMEOUT, allow_redirects=True,
108
+ url,
109
+ headers=profile.headers(url), timeout=TIMEOUT, allow_redirects=True,
106
110
  )
107
111
  if resp.status_code != 200:
108
112
  return []
@@ -115,10 +119,12 @@ class SitemapModule(ScanModule):
115
119
  return []
116
120
 
117
121
  @staticmethod
118
- def _get(url: str) -> bytes | None:
122
+ def _get(url: str, profile: Profile) -> bytes | None:
119
123
  """Fetch a sitemap; gunzip ``.xml.gz`` / gzip payloads by magic bytes."""
120
124
  try:
121
- resp = requests.get(url, headers=DEFAULT_HEADERS, timeout=TIMEOUT, allow_redirects=True)
125
+ resp = requests.get(
126
+ url, headers=profile.headers(url), timeout=TIMEOUT, allow_redirects=True
127
+ )
122
128
  if resp.status_code != 200:
123
129
  return None
124
130
  raw = resp.content
@@ -0,0 +1,119 @@
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
+ Wappalyzer fetches the page *itself* — this is the one target-facing request the scan
10
+ doesn't make through ``net.http`` — and ``analyze()`` takes no header argument, so its
11
+ UA is patched in below.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+
18
+ import requests
19
+ from wappalyzer import analyze
20
+
21
+ from ..core.module import ScanModule
22
+ from ..core.context import ScanContext
23
+ from ..core.models import Grid, Section, Sections
24
+ from ..net.agents import Profile
25
+
26
+ COLUMNS = ["Name", "Category", "Confidence", "Version"]
27
+ #: fixed column widths so every per-group table on the tab lines up identically
28
+ WIDTHS = [26, 22, 12, 12]
29
+ #: bucket for technologies Wappalyzer reports without any group
30
+ UNGROUPED = "Other"
31
+
32
+
33
+ class _UARewrite:
34
+ """Stands in for the ``requests`` module inside ``wappalyzer.core.requester``.
35
+
36
+ Wappalyzer hardcodes its own User-Agent (a Chrome on **X11; Linux**) in a local
37
+ dict inside ``get_response``, and exposes no way to override it. Left alone, the
38
+ target sees two visitors from one IP in the same second — our profile, plus a
39
+ Linux desktop Chrome — which is precisely the inconsistency the profile exists to
40
+ avoid, and Linux desktop is the rarer claim of the two.
41
+
42
+ Why swap the module's ``requests`` attribute rather than patch ``get_response``:
43
+ ``core/analyzer.py`` does ``from ...requester import get_response``, binding the
44
+ function into its own namespace at import time, so patching ``requester.get_response``
45
+ would silently do nothing. The function *body*, though, looks up its module-global
46
+ ``requests`` on every call — so replacing that attribute catches the call wherever
47
+ the function was imported from.
48
+
49
+ Only the User-Agent key is rewritten; the rest of Wappalyzer's header block is
50
+ passed through untouched. That is deliberate — its ``Accept-Encoding: deflate`` is
51
+ load-bearing (see ``net/agents.py`` on advertising encodings you can't decode), and
52
+ passing its dict through rather than restating it means their header block can
53
+ change without us diverging from it.
54
+
55
+ Scoped to Wappalyzer's own module attribute, so our ``requests`` calls are
56
+ unaffected. Re-applying per scan never nests wrappers: ``_real`` is the genuine
57
+ module, captured here, not whatever currently sits on ``requester.requests``.
58
+ """
59
+
60
+ def __init__(self, user_agent: str) -> None:
61
+ self._user_agent = user_agent
62
+ self._real = requests
63
+
64
+ def get(self, url: str, **kwargs: object):
65
+ headers = {**(kwargs.pop("headers", None) or {}), "User-Agent": self._user_agent}
66
+ return self._real.get(url, headers=headers, **kwargs)
67
+
68
+ def __getattr__(self, name: str) -> object:
69
+ # requester also reaches for requests.exceptions.RequestException.
70
+ return getattr(requests, name)
71
+
72
+
73
+ def _wear_profile(profile: Profile) -> None:
74
+ """Point Wappalyzer's request at the scan's identity.
75
+
76
+ Guarded end-to-end: ``wappalyzer.core.requester`` is a private path that a version
77
+ bump could move or restructure. If it isn't where we expect, the tech module simply
78
+ scans with Wappalyzer's own UA — today's behaviour — rather than failing the tab.
79
+ """
80
+ try:
81
+ from wappalyzer.core import requester
82
+
83
+ requester.requests = _UARewrite(profile.user_agent)
84
+ except Exception: # noqa: BLE001 - a moved internal must not cost us the tab
85
+ pass
86
+
87
+
88
+ class TechModule(ScanModule):
89
+ name = "tech"
90
+ label = "Tech"
91
+
92
+ async def run(self, ctx: ScanContext) -> Sections:
93
+ _wear_profile(ctx.profile)
94
+
95
+ def detect() -> Sections:
96
+ # ctx.base, not ctx.url: Wappalyzer fetches the page itself, so on an
97
+ # http-only site an https:// url hands it a connection error and the tab
98
+ # comes back empty. It also keeps what Wappalyzer sees in step with the
99
+ # ctx.html that seo/links/the CMS generator path already read.
100
+ results = analyze(url=ctx.base, scan_type="fast", timeout=30)
101
+ techs = results.get(ctx.base) or next(iter(results.values()), {})
102
+ # group name -> rows; a tech lands in every group it declares
103
+ groups: dict[str, list[list[str]]] = {}
104
+ for name, info in techs.items():
105
+ row = [
106
+ name,
107
+ ", ".join(info.get("categories") or []) or "-",
108
+ f"{info.get('confidence', 0)}%",
109
+ info.get("version") or "-",
110
+ ]
111
+ for group in info.get("groups") or [UNGROUPED]:
112
+ groups.setdefault(group, []).append(row)
113
+ sections = Sections()
114
+ for group in sorted(groups, key=str.lower): # tables A→Z by group
115
+ rows = sorted(groups[group], key=lambda r: r[0].lower()) # names A→Z
116
+ sections.append(Section(group, Grid(COLUMNS, rows, widths=WIDTHS)))
117
+ return sections
118
+
119
+ return await asyncio.to_thread(detect)