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.
- {web_scanner-2.1.3 → web_scanner-2.1.4}/PKG-INFO +43 -14
- {web_scanner-2.1.3 → web_scanner-2.1.4}/README.md +40 -13
- {web_scanner-2.1.3 → web_scanner-2.1.4}/pyproject.toml +5 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/__init__.py +1 -1
- web_scanner-2.1.4/webscanner/core/context.py +72 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/core/scanner.py +1 -1
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/seo.py +7 -5
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/sitemap.py +22 -16
- web_scanner-2.1.4/webscanner/modules/tech.py +119 -0
- web_scanner-2.1.4/webscanner/net/agents.py +153 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/net/http.py +26 -9
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/activity.py +26 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/app.py +4 -3
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/app.tcss +7 -5
- web_scanner-2.1.3/webscanner/core/context.py +0 -41
- web_scanner-2.1.3/webscanner/modules/tech.py +0 -52
- {web_scanner-2.1.3 → web_scanner-2.1.4}/.gitignore +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/LICENSE +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/app.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/requirements.txt +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/__main__.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/cli.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/colors.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/core/__init__.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/core/models.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/core/module.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/helpers.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/__init__.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/dns.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/headers.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/links.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/security.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/ssl.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/subdomains.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/modules/whois.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/net/__init__.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/net/data/public_suffix_list.dat +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/net/psl.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/__init__.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/data/countries.json +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/export.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/tables.py +0 -0
- {web_scanner-2.1.3 → web_scanner-2.1.4}/webscanner/ui/widgets.py +0 -0
- {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
|
+
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
_An async Textual TUI for website reconnaissance — DNS, WHOIS, TLS, security, tech-stack and SEO, with no paid APIs._
|
|
56
|
+
|
|
57
|
+
[](https://www.python.org/downloads/)
|
|
58
|
+
[](https://pypi.org/project/web-scanner/)
|
|
59
|
+
[](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
|
-

|
|
72
|
+

|
|
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
|
|
69
|
-
labelled with
|
|
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
|
-
|
|
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,
|
|
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,
|
|
93
|
-
and detected CMS (name and version
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
_An async Textual TUI for website reconnaissance — DNS, WHOIS, TLS, security, tech-stack and SEO, with no paid APIs._
|
|
6
|
+
|
|
7
|
+
[](https://www.python.org/downloads/)
|
|
8
|
+
[](https://pypi.org/project/web-scanner/)
|
|
9
|
+
[](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
|
-

|
|
22
|
+

|
|
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
|
|
21
|
-
labelled with
|
|
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
|
-
|
|
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,
|
|
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,
|
|
45
|
-
and detected CMS (name and version
|
|
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
|
-
|
|
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
|
|
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"
|
|
@@ -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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
176
|
-
headers=
|
|
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.
|
|
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.
|
|
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,
|
|
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(
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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
|
-
|
|
105
|
-
headers=
|
|
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(
|
|
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
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
286
|
-
|
|
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:
|
|
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:
|
|
144
|
-
the other
|
|
145
|
-
border-box), so visible lines = height - 4:
|
|
146
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|