web-scanner 2.1.2__tar.gz → 2.1.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {web_scanner-2.1.2 → web_scanner-2.1.4}/PKG-INFO +43 -14
- {web_scanner-2.1.2 → web_scanner-2.1.4}/README.md +40 -13
- {web_scanner-2.1.2 → web_scanner-2.1.4}/pyproject.toml +5 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/__init__.py +1 -1
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/colors.py +3 -0
- web_scanner-2.1.4/webscanner/core/context.py +72 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/core/scanner.py +1 -1
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/links.py +7 -2
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/seo.py +7 -5
- {web_scanner-2.1.2 → 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.2 → web_scanner-2.1.4}/webscanner/net/http.py +26 -9
- web_scanner-2.1.4/webscanner/ui/activity.py +352 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/app.py +104 -10
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/app.tcss +35 -1
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/export.py +1 -19
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/tables.py +10 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/widgets.py +116 -2
- web_scanner-2.1.2/webscanner/core/context.py +0 -41
- web_scanner-2.1.2/webscanner/modules/tech.py +0 -52
- {web_scanner-2.1.2 → web_scanner-2.1.4}/.gitignore +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/LICENSE +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/app.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/requirements.txt +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/__main__.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/cli.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/core/__init__.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/core/models.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/core/module.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/helpers.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/__init__.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/dns.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/headers.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/security.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/ssl.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/subdomains.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/modules/whois.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/net/__init__.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/net/data/public_suffix_list.dat +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/net/psl.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/__init__.py +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/data/countries.json +0 -0
- {web_scanner-2.1.2 → web_scanner-2.1.4}/webscanner/ui/worldmap.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: web-scanner
|
|
3
|
-
Version: 2.1.
|
|
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"
|
|
@@ -5,3 +5,6 @@ RED = "#FF6C64"
|
|
|
5
5
|
#: muted value/secondary text — matches the unselected-tab colour
|
|
6
6
|
#: ($text-muted = white @ 60% over the dark theme bg).
|
|
7
7
|
MUTED = "#9D9D9D"
|
|
8
|
+
#: activity-log body text — one step down the white → MUTED ladder, so a log line
|
|
9
|
+
#: reads softer than a heading but stays well clear of the dim MUTED timestamp.
|
|
10
|
+
BODY = "#C6C6C6"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Shared per-scan context passed to every module.
|
|
2
|
+
|
|
3
|
+
The orchestrator's prefetch phase populates the shared network fields (one DNS
|
|
4
|
+
resolve, one HTTP GET, one TLS handshake, one geo lookup) so individual modules
|
|
5
|
+
reuse them instead of refetching.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urlparse
|
|
13
|
+
|
|
14
|
+
from .. import helpers
|
|
15
|
+
from ..net.agents import Profile, random_profile
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class ScanContext:
|
|
20
|
+
# inputs
|
|
21
|
+
domain: str # bare host, e.g. "example.com"
|
|
22
|
+
url: str # full url, e.g. "https://example.com"
|
|
23
|
+
|
|
24
|
+
#: The browser identity every target-facing request wears. Chosen once per scan
|
|
25
|
+
#: and shared, so the site sees one consistent visitor rather than a different
|
|
26
|
+
#: browser per request — see net/agents.py. Not used for ip-api/DoH.
|
|
27
|
+
profile: Profile = field(default_factory=random_profile)
|
|
28
|
+
|
|
29
|
+
# shared prefetch results (filled by AsyncScanner.prefetch)
|
|
30
|
+
ip: str | None = None
|
|
31
|
+
html: str | None = None
|
|
32
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
33
|
+
status_code: int | None = None
|
|
34
|
+
response_time_ms: float | None = None
|
|
35
|
+
final_url: str | None = None
|
|
36
|
+
redirect_status: str | None = None # e.g. "301 Moved Permanently" for cross-domain redirects
|
|
37
|
+
tls_cert: dict[str, Any] | None = None
|
|
38
|
+
geo: dict[str, Any] | None = None
|
|
39
|
+
fetch_error: str | None = None
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_target(cls, target: str) -> "ScanContext":
|
|
43
|
+
domain, url = helpers.normalise(target)
|
|
44
|
+
return cls(domain=domain, url=url)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def base(self) -> str:
|
|
48
|
+
"""Origin to build target URLs from: ``<scheme>://<domain>``.
|
|
49
|
+
|
|
50
|
+
The scheme is the one prefetch actually *reached*, not the one we asked for.
|
|
51
|
+
``helpers.normalise()`` always hands us ``https://`` regardless of what the
|
|
52
|
+
user typed, so :attr:`url` is not a scheme signal; ``http.fetch()`` retries
|
|
53
|
+
over ``http://`` when https won't connect, and :attr:`final_url` records
|
|
54
|
+
where it landed. An http-only site that builds its URLs from :attr:`url`
|
|
55
|
+
silently gets nothing back (no robots.txt, no sitemap, no tech) while the
|
|
56
|
+
rest of the scan succeeds.
|
|
57
|
+
|
|
58
|
+
The host comes from :attr:`domain`, *not* from ``final_url``: a bare→www
|
|
59
|
+
redirect would otherwise pin us to ``www.example.com``, and requests we
|
|
60
|
+
build follow redirects there anyway.
|
|
61
|
+
|
|
62
|
+
**Only valid after prefetch.** Before it (``final_url`` is None) this falls
|
|
63
|
+
back to https — which is also the right answer when the fetch failed
|
|
64
|
+
outright, since nothing else will connect either. ``prefetch()`` itself must
|
|
65
|
+
never read this: it is what determines the scheme.
|
|
66
|
+
"""
|
|
67
|
+
scheme = urlparse(self.final_url).scheme if self.final_url else ""
|
|
68
|
+
return f"{scheme or 'https'}://{self.domain}"
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def online(self) -> bool:
|
|
72
|
+
return self.status_code is not None and self.fetch_error is None
|
|
@@ -56,7 +56,7 @@ class AsyncScanner:
|
|
|
56
56
|
|
|
57
57
|
async def _fetch() -> None:
|
|
58
58
|
try:
|
|
59
|
-
r = await asyncio.to_thread(http.fetch, self.ctx.url)
|
|
59
|
+
r = await asyncio.to_thread(http.fetch, self.ctx.url, self.ctx.profile)
|
|
60
60
|
self.ctx.status_code = r["status_code"]
|
|
61
61
|
self.ctx.headers = r["headers"]
|
|
62
62
|
self.ctx.html = r["html"]
|
|
@@ -18,6 +18,11 @@ from ..core.models import Section, Sections
|
|
|
18
18
|
|
|
19
19
|
_SKIP_PREFIXES = ("#", "javascript:", "mailto:", "tel:", "data:")
|
|
20
20
|
|
|
21
|
+
# Placeholder row shown when a section found nothing. Named so callers can tell
|
|
22
|
+
# "no links" from "one link" — the section is never empty, so len() can't.
|
|
23
|
+
EMPTY_INTERNAL = ("—", "no internal links found")
|
|
24
|
+
EMPTY_EXTERNAL = ("—", "no external links found")
|
|
25
|
+
|
|
21
26
|
|
|
22
27
|
class LinksModule(ScanModule):
|
|
23
28
|
name = "links"
|
|
@@ -31,8 +36,8 @@ class LinksModule(ScanModule):
|
|
|
31
36
|
internal, external = await asyncio.to_thread(self._parse, ctx)
|
|
32
37
|
|
|
33
38
|
return Sections([
|
|
34
|
-
Section("Internal", internal or [
|
|
35
|
-
Section("External", external or [
|
|
39
|
+
Section("Internal", internal or [EMPTY_INTERNAL], ("Link text", "URL")),
|
|
40
|
+
Section("External", external or [EMPTY_EXTERNAL], ("Link text", "URL")),
|
|
36
41
|
])
|
|
37
42
|
|
|
38
43
|
@staticmethod
|
|
@@ -26,7 +26,8 @@ from ..colors import GREEN, RED, MUTED
|
|
|
26
26
|
from ..core.module import ScanModule
|
|
27
27
|
from ..core.context import ScanContext
|
|
28
28
|
from ..core.models import Section, Sections
|
|
29
|
-
from ..net.
|
|
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)
|