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