web-scanner 2.1.3__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.3 → web_scanner-2.1.4}/PKG-INFO +43 -14
  2. {web_scanner-2.1.3 → web_scanner-2.1.4}/README.md +40 -13
  3. {web_scanner-2.1.3 → web_scanner-2.1.4}/pyproject.toml +5 -0
  4. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/__init__.py +1 -1
  5. web_scanner-2.1.4/webscanner/core/context.py +72 -0
  6. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/core/scanner.py +1 -1
  7. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/seo.py +7 -5
  8. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/sitemap.py +22 -16
  9. web_scanner-2.1.4/webscanner/modules/tech.py +119 -0
  10. web_scanner-2.1.4/webscanner/net/agents.py +153 -0
  11. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/net/http.py +26 -9
  12. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/activity.py +26 -0
  13. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/app.py +4 -3
  14. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/app.tcss +7 -5
  15. web_scanner-2.1.3/webscanner/core/context.py +0 -41
  16. web_scanner-2.1.3/webscanner/modules/tech.py +0 -52
  17. {web_scanner-2.1.3 → web_scanner-2.1.4}/.gitignore +0 -0
  18. {web_scanner-2.1.3 → web_scanner-2.1.4}/LICENSE +0 -0
  19. {web_scanner-2.1.3 → web_scanner-2.1.4}/app.py +0 -0
  20. {web_scanner-2.1.3 → web_scanner-2.1.4}/requirements.txt +0 -0
  21. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/__main__.py +0 -0
  22. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/cli.py +0 -0
  23. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/colors.py +0 -0
  24. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/core/__init__.py +0 -0
  25. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/core/models.py +0 -0
  26. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/core/module.py +0 -0
  27. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/helpers.py +0 -0
  28. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/__init__.py +0 -0
  29. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/dns.py +0 -0
  30. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/headers.py +0 -0
  31. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/links.py +0 -0
  32. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/security.py +0 -0
  33. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/ssl.py +0 -0
  34. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/subdomains.py +0 -0
  35. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/whois.py +0 -0
  36. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/net/__init__.py +0 -0
  37. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/net/data/public_suffix_list.dat +0 -0
  38. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/net/psl.py +0 -0
  39. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/__init__.py +0 -0
  40. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/data/countries.json +0 -0
  41. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/export.py +0 -0
  42. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/tables.py +0 -0
  43. {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/widgets.py +0 -0
  44. {web_scanner-2.1.3 → 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.3
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.3"
3
+ __version__ = "2.1.4"
@@ -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"]
@@ -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)
@@ -0,0 +1,153 @@
1
+ """Browser identities for target-facing requests.
2
+
3
+ One :class:`Profile` is one coherent desktop-Chrome identity: a User-Agent plus the
4
+ headers that Chrome genuinely sends beside it. They live together because a UA on its
5
+ own is *worse* than no disguise — ``requests`` defaults to ``Accept: */*`` and no
6
+ ``Sec-Fetch-*``/``Sec-CH-UA``, and a "Chrome" that sends those is trivially spotted by
7
+ the mid-tier WAFs that then serve a block page instead of the site. Getting real
8
+ content back is the point; looking natural is how.
9
+
10
+ Scope: **target-facing requests only** (homepage, robots.txt, sitemaps). ip-api and
11
+ Cloudflare DoH keep the honest ``http.USER_AGENT`` — they don't block, don't care, and
12
+ a browser UA beside ``accept: application/dns-json`` would be incoherent anyway.
13
+
14
+ Why the pool is all recent desktop Chrome, and not a browser zoo: the scan picks one
15
+ profile and reuses it, so rotation only ever varies things *between* scans. Sites do
16
+ serve different markup to Safari vs Firefox, so a mixed pool would make two scans of
17
+ one domain disagree — a reliability cost paid for a stealth benefit that isn't real,
18
+ since the IP and TLS fingerprint are identical across scans regardless. Chrome N vs
19
+ N-2 on Windows vs macOS is variation *below the threshold where sites branch*, so
20
+ rotation costs nothing here. Chrome-on-Windows is also the most common UA on earth:
21
+ the most boring thing you can claim. Pin ``CHROME_VERSIONS``/``PLATFORMS`` to one
22
+ entry each if you ever want scans fully deterministic.
23
+
24
+ Known ceiling: this controls headers, not the TLS (JA3/JA4) or HTTP/2 fingerprint.
25
+ ``requests`` speaks HTTP/1.1 with an OpenSSL handshake no Chrome would produce, so
26
+ against enterprise bot management (Cloudflare, Akamai) the disguise does not hold —
27
+ those read the handshake, not the string. This buys the naive-UA-filter case, which is
28
+ nearly all of them. Header *order* is likewise not ours to set (requests merges its own
29
+ session defaults in first). Don't chase either through this file; the lever there is a
30
+ TLS-impersonating client such as curl_cffi, which is a compiled dependency and a much
31
+ bigger trade.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import random
37
+ from dataclasses import dataclass
38
+
39
+ #: Chrome stable majors, newest first — current + two back, which is roughly how the
40
+ #: real Chrome population spreads across a release window.
41
+ #:
42
+ #: **Checked against the Chrome release API on 2026-07-17 (stable was 151).** These
43
+ #: rot: a UA claiming a Chrome that shipped 18 months ago is a louder signal than an
44
+ #: honest scanner UA, which defeats the whole file. Re-check when you next touch this:
45
+ #: https://versionhistory.googleapis.com/v1/chrome/platforms/win/channels/stable/versions
46
+ CHROME_VERSIONS: tuple[str, ...] = ("151", "150", "149")
47
+
48
+ #: (UA platform token, sec-ch-ua-platform value). Both are frozen strings in modern
49
+ #: Chrome: its reduced UA reports every macOS as "10_15_7" and every Windows as
50
+ #: "Windows NT 10.0" no matter the real OS, so these are correct as literals rather
51
+ #: than lazily hardcoded.
52
+ PLATFORMS: tuple[tuple[str, str], ...] = (
53
+ ("Windows NT 10.0; Win64; x64", "Windows"),
54
+ ("Macintosh; Intel Mac OS X 10_15_7", "macOS"),
55
+ )
56
+
57
+ #: What Chrome sends for a top-level navigation. A homepage/robots.txt/sitemap.xml GET
58
+ #: is exactly what you'd get by typing the URL in the bar, so one navigation header set
59
+ #: covers all three honestly.
60
+ _ACCEPT = (
61
+ "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,"
62
+ "image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7"
63
+ )
64
+
65
+ #: Real Chrome 151 sends "gzip, deflate, br, zstd". We deliberately advertise less.
66
+ #:
67
+ #: This is the one place we knowingly diverge from the browser, and it is not an
68
+ #: oversight: brotli needs the `brotli` package and zstd needs `zstandard` (or a
69
+ #: Python 3.14 stdlib that `requires-python = ">=3.11"` does not promise). Advertise an
70
+ #: encoding we can't decode and the body comes back as bytes-shaped garbage — which
71
+ #: silently poisons ctx.html and, with it, seo, links and tech. A hypothetical
72
+ #: fingerprinter noticing the missing `br` beats a guaranteed corrupted scan.
73
+ #:
74
+ #: If you ever want the profile fully coherent, add `brotli` as a dependency and put
75
+ #: `br` back — don't add `br` without it.
76
+ _ACCEPT_ENCODING = "gzip, deflate"
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Profile:
81
+ """One browser identity, chosen once per scan and reused for every request to the
82
+ target — a real browser keeps one identity per session. Rotating per *request*
83
+ would be the louder tell: one IP fetching `/` as Chrome and 60 sitemaps as Safari
84
+ within a few seconds is not a thing a browser does."""
85
+
86
+ chrome: str
87
+ """Chrome major version, e.g. "151"."""
88
+
89
+ ua_platform: str
90
+ """The platform token inside the UA string."""
91
+
92
+ platform: str
93
+ """The `sec-ch-ua-platform` value, e.g. "Windows"."""
94
+
95
+ @property
96
+ def user_agent(self) -> str:
97
+ # Chrome freezes the minor version at 0.0.0 in its reduced UA — the real build
98
+ # number (151.0.7922.34) never appears, so writing one would be the error.
99
+ return (
100
+ f"Mozilla/5.0 ({self.ua_platform}) AppleWebKit/537.36 (KHTML, like Gecko) "
101
+ f"Chrome/{self.chrome}.0.0.0 Safari/537.36"
102
+ )
103
+
104
+ @property
105
+ def label(self) -> str:
106
+ """Short human form for the activity log — "Chrome 151 · Windows"."""
107
+ return f"Chrome {self.chrome} · {self.platform}"
108
+
109
+ def headers(self, url: str) -> dict[str, str]:
110
+ """The full header set for a GET of ``url``.
111
+
112
+ Client hints are dropped for plaintext ``http://``: they're a secure-context
113
+ feature, so real Chrome doesn't send them there and doing so would contradict
114
+ the very UA they're meant to corroborate. This matters because ``http.fetch``
115
+ retries over http:// when TLS fails.
116
+ """
117
+ headers = {
118
+ "User-Agent": self.user_agent,
119
+ "Accept": _ACCEPT,
120
+ "Accept-Language": "en-US,en;q=0.9",
121
+ "Accept-Encoding": _ACCEPT_ENCODING,
122
+ "Upgrade-Insecure-Requests": "1",
123
+ "Sec-Fetch-Dest": "document",
124
+ "Sec-Fetch-Mode": "navigate",
125
+ "Sec-Fetch-Site": "none",
126
+ "Sec-Fetch-User": "?1",
127
+ "Connection": "keep-alive",
128
+ }
129
+ if url.lower().startswith("https://"):
130
+ headers |= {
131
+ # The "Not?A_Brand" entry is GREASE: deliberately arbitrary padding
132
+ # Chrome varies to stop anyone parsing this list rigidly. Any plausible
133
+ # value is as correct as another — it is designed to be ignored.
134
+ "sec-ch-ua": (
135
+ f'"Chromium";v="{self.chrome}", '
136
+ f'"Google Chrome";v="{self.chrome}", '
137
+ f'"Not?A_Brand";v="24"'
138
+ ),
139
+ "sec-ch-ua-mobile": "?0",
140
+ "sec-ch-ua-platform": f'"{self.platform}"',
141
+ }
142
+ return headers
143
+
144
+
145
+ def random_profile() -> Profile:
146
+ """Pick one identity for a scan. See the module docstring for why the pool is
147
+ narrow — the randomness is confined to axes sites don't branch on."""
148
+ ua_platform, platform = random.choice(PLATFORMS)
149
+ return Profile(
150
+ chrome=random.choice(CHROME_VERSIONS),
151
+ ua_platform=ua_platform,
152
+ platform=platform,
153
+ )
@@ -1,8 +1,21 @@
1
1
  """Blocking HTTP / DNS / TLS primitives.
2
2
 
3
3
  Kept synchronous on purpose: the async core calls these via ``asyncio.to_thread``.
4
- Centralising them here gives every module one user-agent, one timeout policy and
5
- one place to add retries/proxying later.
4
+ Centralising them here gives every module one timeout policy and one place to add
5
+ retries/proxying later.
6
+
7
+ Two identities live here, and the split is deliberate:
8
+
9
+ * **Target-facing** requests (homepage, robots.txt, sitemaps) wear the scan's
10
+ :class:`~webscanner.net.agents.Profile` — a browser identity, passed in by the
11
+ caller from ``ctx.profile``. See ``agents.py`` for why.
12
+ * **Third-party APIs** (ip-api, Cloudflare DoH) use :data:`API_HEADERS`, which
13
+ identifies the tool honestly. They don't block and don't care, and there's no
14
+ reason to wear a costume for a free service we depend on.
15
+
16
+ ``API_HEADERS`` is named for that job rather than "default" on purpose: there is no
17
+ sensible default here, and a name like ``DEFAULT_HEADERS`` is exactly what someone
18
+ reaches for by accident on a target request.
6
19
  """
7
20
 
8
21
  from __future__ import annotations
@@ -15,14 +28,16 @@ from urllib.parse import urljoin, urlparse
15
28
 
16
29
  import requests
17
30
 
31
+ from .agents import Profile
18
32
  from .psl import registrable_domain
19
33
 
20
34
  USER_AGENT = "web-scanner/2.0 (+https://github.com/iamramizk/web-scanner)"
21
35
  TIMEOUT = 12
22
- DEFAULT_HEADERS = {"User-Agent": USER_AGENT}
36
+ #: For third-party APIs only — never for the target. See the module docstring.
37
+ API_HEADERS = {"User-Agent": USER_AGENT}
23
38
 
24
39
 
25
- def fetch(url: str) -> dict[str, Any]:
40
+ def fetch(url: str, profile: Profile) -> dict[str, Any]:
26
41
  """GET a URL, following redirects; return status/headers/body/timing/final-url.
27
42
 
28
43
  ``redirect`` is set (e.g. "301 Moved Permanently") only when a redirect crossed
@@ -36,16 +51,18 @@ def fetch(url: str) -> dict[str, Any]:
36
51
  is a normal response, not a connection failure, and does not trigger a retry.
37
52
  """
38
53
  try:
39
- return _get(url)
54
+ return _get(url, profile)
40
55
  except requests.exceptions.RequestException:
41
56
  if url.lower().startswith("https://"):
42
- return _get("http://" + url[len("https://"):])
57
+ return _get("http://" + url[len("https://"):], profile)
43
58
  raise
44
59
 
45
60
 
46
- def _get(url: str) -> dict[str, Any]:
61
+ def _get(url: str, profile: Profile) -> dict[str, Any]:
47
62
  start = time.perf_counter()
48
- resp = requests.get(url, headers=DEFAULT_HEADERS, timeout=TIMEOUT, allow_redirects=True)
63
+ # headers(url), not a cached dict: the http:// retry above must drop the client
64
+ # hints, which are https-only.
65
+ resp = requests.get(url, headers=profile.headers(url), timeout=TIMEOUT, allow_redirects=True)
49
66
  elapsed = (time.perf_counter() - start) * 1000
50
67
 
51
68
  orig = registrable_domain(urlparse(url).hostname)
@@ -76,7 +93,7 @@ def resolve_ip(domain: str) -> str | None:
76
93
 
77
94
  def get_geo(ip: str) -> dict[str, Any]:
78
95
  """Geolocate an IP via ip-api.com (free, no key)."""
79
- resp = requests.get(f"http://ip-api.com/json/{ip}", headers=DEFAULT_HEADERS, timeout=TIMEOUT)
96
+ resp = requests.get(f"http://ip-api.com/json/{ip}", headers=API_HEADERS, timeout=TIMEOUT)
80
97
  return resp.json()
81
98
 
82
99
 
@@ -31,6 +31,7 @@ from ..colors import GREEN, RED
31
31
  from ..core.context import ScanContext
32
32
  from ..core.models import ModuleResult, ModuleStatus, ScanEvent
33
33
  from ..core.scanner import PREFETCH
34
+ from ..net.agents import Profile
34
35
  from ..modules import all_modules
35
36
  from ..modules.dns import RECORD_TYPES
36
37
  from ..modules.links import EMPTY_EXTERNAL, EMPTY_INTERNAL
@@ -273,6 +274,31 @@ def started(target: str, count: int) -> str:
273
274
  return f"Scan: Started {_esc(target)} · {_plural(count, 'module')}."
274
275
 
275
276
 
277
+ def agent(profile: Profile) -> str:
278
+ """The browser identity this scan wore.
279
+
280
+ Worth a line because the profile is chosen at random per scan: when a scan returns
281
+ something surprising (a block page, a tech list that shifted since last run), the
282
+ first question is what the site thought we were, and the answer differs run to run.
283
+ The label — "Chrome 151 · Windows" — is enough to reproduce it; the full UA string
284
+ is ~110 chars and would be ellipsised to no benefit.
285
+ """
286
+ return f"Agent: {_esc(profile.label)}."
287
+
288
+
289
+ def cms(detected: tuple[str, str | None] | None) -> str:
290
+ """The Server panel's CMS row, as a log line — wording kept identical to it.
291
+
292
+ Not a module: the UI derives this from the Tech result plus the page's
293
+ ``<meta name="generator">``, so ``app.py`` calls this straight after the Tech
294
+ event and the line lands directly under Tech's.
295
+ """
296
+ if detected is None:
297
+ return "CMS: Not detected."
298
+ name, version = detected
299
+ return f"CMS: {_esc(f'{name} {version}' if version else name)}."
300
+
301
+
276
302
  def overall(completed: int, failed: int, total: int, seconds: float) -> str:
277
303
  """The closing line. ``seconds`` is wall-clock for the whole scan — modules run
278
304
  concurrently, so summing their durations would overstate it several-fold."""
@@ -232,6 +232,7 @@ class WebScannerApp(App):
232
232
  log = self.query_one("#activity", ActivityLog)
233
233
  log.clear()
234
234
  log.add(activity.started(self.ctx.domain, len(self.modules)))
235
+ log.add(activity.agent(self.ctx.profile))
235
236
  self._update_progress()
236
237
 
237
238
  # Blur the input so single-key nav (←/→, q, r) reaches the app rather
@@ -282,9 +283,9 @@ class WebScannerApp(App):
282
283
  # No result guard: a failed Tech scan can still surface a CMS via the
283
284
  # page's <meta name="generator">.
284
285
  data = event.result.data if event.result is not None else None
285
- self.query_one("#status-content", StatusPanel).set_ctx(
286
- self.ctx, cms=_detect_cms(data, self.ctx.html)
287
- )
286
+ detected = _detect_cms(data, self.ctx.html)
287
+ self.query_one("#status-content", StatusPanel).set_ctx(self.ctx, cms=detected)
288
+ self.query_one("#activity", ActivityLog).add(activity.cms(detected))
288
289
  self._update_progress()
289
290
 
290
291
  def on_scan_finished(self, message: ScanFinished) -> None:
@@ -81,7 +81,7 @@ Tab.-selected {
81
81
  }
82
82
 
83
83
  #main {
84
- height: 4fr; /* 80% of the left column (activity takes the other 20%). Must
84
+ height: 3fr; /* 75% of the left column (activity takes the other 25%). Must
85
85
  stay fr, NOT 100%: that claims the whole column and silently
86
86
  clips #activity (Vertical is overflow: hidden, so no
87
87
  scrollbar, no error — the panel just never appears). */
@@ -140,10 +140,12 @@ Tab.-selected {
140
140
  link-background-hover: transparent;
141
141
  }
142
142
 
143
- /* Activity log: 20% of the left column, so it grows with the terminal (#main is
144
- the other 80%). 4 rows of that go to the border + padding (box-sizing is
145
- border-box), so visible lines = height - 4: ~3 at 44 rows, ~6 at 60.
146
- min-height floors it at one readable line on a short terminal, where 20% would
143
+ /* Activity log: 25% of the left column, so it grows with the terminal (#main is
144
+ the other 75%). 4 rows of that go to the border + padding (box-sizing is
145
+ border-box), so visible lines = height - 4: 5 at 44 rows, 9 at 60, 14 at 80
146
+ (measured, not derived heights are whole cells, and Textual hands the rounding
147
+ remainder to the last child).
148
+ min-height floors it at one readable line on a short terminal, where 25% would
147
149
  otherwise round down to nothing but chrome.
148
150
  background: transparent overrides RichLog's $surface, which would punch an
149
151
  opaque block through the ansi_default bg. */
@@ -1,41 +0,0 @@
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
-
13
- from .. import helpers
14
-
15
-
16
- @dataclass
17
- class ScanContext:
18
- # inputs
19
- domain: str # bare host, e.g. "example.com"
20
- url: str # full url, e.g. "https://example.com"
21
-
22
- # shared prefetch results (filled by AsyncScanner.prefetch)
23
- ip: str | None = None
24
- html: str | None = None
25
- headers: dict[str, str] = field(default_factory=dict)
26
- status_code: int | None = None
27
- response_time_ms: float | None = None
28
- final_url: str | None = None
29
- redirect_status: str | None = None # e.g. "301 Moved Permanently" for cross-domain redirects
30
- tls_cert: dict[str, Any] | None = None
31
- geo: dict[str, Any] | None = None
32
- fetch_error: str | None = None
33
-
34
- @classmethod
35
- def from_target(cls, target: str) -> "ScanContext":
36
- domain, url = helpers.normalise(target)
37
- return cls(domain=domain, url=url)
38
-
39
- @property
40
- def online(self) -> bool:
41
- return self.status_code is not None and self.fetch_error is None
@@ -1,52 +0,0 @@
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)
File without changes
File without changes
File without changes