web-scanner 2.2.2__tar.gz → 2.2.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.2.2 → web_scanner-2.2.3}/PKG-INFO +19 -7
- {web_scanner-2.2.2 → web_scanner-2.2.3}/README.md +18 -6
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/__init__.py +1 -1
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/dns.py +12 -1
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/activity.py +8 -1
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/app.py +84 -30
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/tables.py +5 -2
- {web_scanner-2.2.2 → web_scanner-2.2.3}/.gitignore +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/LICENSE +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/app.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/pyproject.toml +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/requirements.txt +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/__main__.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/cli.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/colors.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/core/__init__.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/core/context.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/core/models.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/core/module.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/core/scanner.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/helpers.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/__init__.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/headers.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/links.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/security.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/seo.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/sitemap.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/ssl.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/subdomains.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/tech.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/whois.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/__init__.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/agents.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/data/public_suffix_list.dat +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/http.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/psl.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/version_check.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/__init__.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/app.tcss +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/data/countries.json +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/export.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/widgets.py +0 -0
- {web_scanner-2.2.2 → web_scanner-2.2.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.2.
|
|
3
|
+
Version: 2.2.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
|
|
@@ -69,9 +69,7 @@ a server-status panel that stay pinned in place.
|
|
|
69
69
|
resolvers for blocklist checks, the system `whois`, and stdlib `ssl`/`socket`. Country
|
|
70
70
|
borders are embedded (Natural Earth), so even the map needs no tile service.
|
|
71
71
|
|
|
72
|
-

|
|
72
|
+

|
|
75
73
|
|
|
76
74
|
## Contents
|
|
77
75
|
|
|
@@ -130,7 +128,11 @@ Alongside the tabs, three fixed panels:
|
|
|
130
128
|
- **Server** — online status and response time, the final URL after redirects, IP,
|
|
131
129
|
geolocation, ISP, AS, hosting provider and detected CMS (name and version). When many
|
|
132
130
|
sites share the server's IP, it flags the address as shared and counts how many distinct
|
|
133
|
-
domains resolve to it (via a free reverse-IP lookup)
|
|
131
|
+
domains resolve to it (via a free reverse-IP lookup, [hackertarget](https://hackertarget.com/),
|
|
132
|
+
rate-limited to 50 requests/day).
|
|
133
|
+
|
|
134
|
+
The layout is **responsive**: on a narrow terminal it collapses to a single full-width
|
|
135
|
+
column and the map, Server and Activity panels fold into their own tabs.
|
|
134
136
|
|
|
135
137
|
Requests to the site being scanned wear a **coherent desktop-Chrome identity** — a real
|
|
136
138
|
User-Agent plus the headers Chrome actually sends beside it — picked once per scan and
|
|
@@ -174,11 +176,19 @@ webscan example.com
|
|
|
174
176
|
You can also run it as a module (`python -m webscanner example.com`), or from a source
|
|
175
177
|
checkout without installing (`python app.py example.com`).
|
|
176
178
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
Once a scan finishes you can stay in the app: press `r` to rescan the same domain, or `esc`
|
|
180
|
+
to edit the domain and scan a different one — no need to restart.
|
|
181
|
+
|
|
182
|
+
Press `s` to save every tab to CSV (plus a `server.csv` for the fixed Server panel, which
|
|
183
|
+
has no tab of its own). Files go into a `<domain>_<timestamp>/` folder — under `output/`
|
|
184
|
+
when running from a source checkout, or straight in your current directory when installed.
|
|
179
185
|
|
|
180
186
|
## Updating
|
|
181
187
|
|
|
188
|
+
The app checks PyPI in the background once a day and, when a newer release is out, marks the
|
|
189
|
+
version in the footer with an orange dot and adds a line to the Activity Log — so you'll know
|
|
190
|
+
when it's worth upgrading.
|
|
191
|
+
|
|
182
192
|
```bash
|
|
183
193
|
pipx upgrade web-scanner
|
|
184
194
|
```
|
|
@@ -195,6 +205,8 @@ pipx install --force git+https://github.com/iamramizk/web-scanner
|
|
|
195
205
|
| Key | Action |
|
|
196
206
|
| ---------------- | ------------------------------------------------------------------- |
|
|
197
207
|
| `←` / `→` `Tab` | Switch tabs |
|
|
208
|
+
| `1`–`9` `0` | Jump straight to the Nth tab (`0` = 10th) |
|
|
209
|
+
| `PgUp` / `PgDn` | Scroll the main panel up / down |
|
|
198
210
|
| `+` / `-` | Zoom the country map in / out |
|
|
199
211
|
| `↑` / `↓` `enter`| Navigate the Sitemap tree (`space` expands/collapses all) |
|
|
200
212
|
| `r` | Rescan |
|
|
@@ -19,9 +19,7 @@ a server-status panel that stay pinned in place.
|
|
|
19
19
|
resolvers for blocklist checks, the system `whois`, and stdlib `ssl`/`socket`. Country
|
|
20
20
|
borders are embedded (Natural Earth), so even the map needs no tile service.
|
|
21
21
|
|
|
22
|
-

|
|
22
|
+

|
|
25
23
|
|
|
26
24
|
## Contents
|
|
27
25
|
|
|
@@ -80,7 +78,11 @@ Alongside the tabs, three fixed panels:
|
|
|
80
78
|
- **Server** — online status and response time, the final URL after redirects, IP,
|
|
81
79
|
geolocation, ISP, AS, hosting provider and detected CMS (name and version). When many
|
|
82
80
|
sites share the server's IP, it flags the address as shared and counts how many distinct
|
|
83
|
-
domains resolve to it (via a free reverse-IP lookup)
|
|
81
|
+
domains resolve to it (via a free reverse-IP lookup, [hackertarget](https://hackertarget.com/),
|
|
82
|
+
rate-limited to 50 requests/day).
|
|
83
|
+
|
|
84
|
+
The layout is **responsive**: on a narrow terminal it collapses to a single full-width
|
|
85
|
+
column and the map, Server and Activity panels fold into their own tabs.
|
|
84
86
|
|
|
85
87
|
Requests to the site being scanned wear a **coherent desktop-Chrome identity** — a real
|
|
86
88
|
User-Agent plus the headers Chrome actually sends beside it — picked once per scan and
|
|
@@ -124,11 +126,19 @@ webscan example.com
|
|
|
124
126
|
You can also run it as a module (`python -m webscanner example.com`), or from a source
|
|
125
127
|
checkout without installing (`python app.py example.com`).
|
|
126
128
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
Once a scan finishes you can stay in the app: press `r` to rescan the same domain, or `esc`
|
|
130
|
+
to edit the domain and scan a different one — no need to restart.
|
|
131
|
+
|
|
132
|
+
Press `s` to save every tab to CSV (plus a `server.csv` for the fixed Server panel, which
|
|
133
|
+
has no tab of its own). Files go into a `<domain>_<timestamp>/` folder — under `output/`
|
|
134
|
+
when running from a source checkout, or straight in your current directory when installed.
|
|
129
135
|
|
|
130
136
|
## Updating
|
|
131
137
|
|
|
138
|
+
The app checks PyPI in the background once a day and, when a newer release is out, marks the
|
|
139
|
+
version in the footer with an orange dot and adds a line to the Activity Log — so you'll know
|
|
140
|
+
when it's worth upgrading.
|
|
141
|
+
|
|
132
142
|
```bash
|
|
133
143
|
pipx upgrade web-scanner
|
|
134
144
|
```
|
|
@@ -145,6 +155,8 @@ pipx install --force git+https://github.com/iamramizk/web-scanner
|
|
|
145
155
|
| Key | Action |
|
|
146
156
|
| ---------------- | ------------------------------------------------------------------- |
|
|
147
157
|
| `←` / `→` `Tab` | Switch tabs |
|
|
158
|
+
| `1`–`9` `0` | Jump straight to the Nth tab (`0` = 10th) |
|
|
159
|
+
| `PgUp` / `PgDn` | Scroll the main panel up / down |
|
|
148
160
|
| `+` / `-` | Zoom the country map in / out |
|
|
149
161
|
| `↑` / `↓` `enter`| Navigate the Sitemap tree (`space` expands/collapses all) |
|
|
150
162
|
| `r` | Rescan |
|
|
@@ -151,6 +151,12 @@ class DnsModule(ScanModule):
|
|
|
151
151
|
out[rtype] = res
|
|
152
152
|
return out
|
|
153
153
|
|
|
154
|
+
async def www_cname() -> list[str]:
|
|
155
|
+
# The apex almost never has a CNAME (forbidden alongside its SOA/NS), so
|
|
156
|
+
# the standard CNAME lookup above is usually empty. Hosting providers put
|
|
157
|
+
# the CNAME on `www.` instead — surface it when the apex has none.
|
|
158
|
+
return await asyncio.to_thread(pydig.query, f"www.{domain}", "CNAME")
|
|
159
|
+
|
|
154
160
|
async def dmarc() -> list[str]:
|
|
155
161
|
txt = await asyncio.to_thread(pydig.query, f"_dmarc.{domain}", "TXT")
|
|
156
162
|
return [t for t in txt if "dmarc1" in t.lower()]
|
|
@@ -159,12 +165,17 @@ class DnsModule(ScanModule):
|
|
|
159
165
|
txt = await asyncio.to_thread(pydig.query, f"{sel}._domainkey.{domain}", "TXT")
|
|
160
166
|
return sel if txt and _is_dkim(txt) else None
|
|
161
167
|
|
|
162
|
-
out, dmarc_records, dkim_hits = await asyncio.gather(
|
|
168
|
+
out, www, dmarc_records, dkim_hits = await asyncio.gather(
|
|
163
169
|
asyncio.to_thread(records),
|
|
170
|
+
www_cname(),
|
|
164
171
|
dmarc(),
|
|
165
172
|
asyncio.gather(*(dkim_selector(s) for s in DKIM_SELECTORS)),
|
|
166
173
|
)
|
|
167
174
|
|
|
175
|
+
# Show the www CNAME when the apex has none (the common hosting-provider case).
|
|
176
|
+
if www and "CNAME" not in out:
|
|
177
|
+
out["CNAME (www)"] = www
|
|
178
|
+
|
|
168
179
|
if dmarc_records:
|
|
169
180
|
out["DMARC"] = dmarc_records
|
|
170
181
|
found = [s for s in dkim_hits if s]
|
|
@@ -29,7 +29,7 @@ from urllib.parse import urlparse
|
|
|
29
29
|
|
|
30
30
|
from rich.markup import escape
|
|
31
31
|
|
|
32
|
-
from ..colors import GREEN, RED
|
|
32
|
+
from ..colors import BLUE, GREEN, RED
|
|
33
33
|
from ..net.psl import registrable_domain
|
|
34
34
|
from ..core.context import ScanContext
|
|
35
35
|
from ..core.models import ModuleResult, ModuleStatus, ScanEvent
|
|
@@ -364,6 +364,13 @@ def waf(result: ModuleResult) -> str | None:
|
|
|
364
364
|
return f"{line} · actively blocking." if blocked else f"{line}."
|
|
365
365
|
|
|
366
366
|
|
|
367
|
+
def update_available(latest: str) -> str:
|
|
368
|
+
"""A newer release exists on PyPI. Emitted once, after the scan's own closing
|
|
369
|
+
line, only when a newer version was actually found — see net/version_check.py.
|
|
370
|
+
Complements (does not replace) the status-bar version dot."""
|
|
371
|
+
return f"Update: [{BLUE}]v{_esc(latest)} available[/] — run `pipx upgrade web-scanner`."
|
|
372
|
+
|
|
373
|
+
|
|
367
374
|
def overall(completed: int, failed: int, total: int, seconds: float) -> str:
|
|
368
375
|
"""The closing line. ``seconds`` is wall-clock for the whole scan — modules run
|
|
369
376
|
concurrently, so summing their durations would overstate it several-fold."""
|
|
@@ -25,7 +25,7 @@ from textual.message import Message
|
|
|
25
25
|
from textual.widgets import Input, LoadingIndicator, Static
|
|
26
26
|
|
|
27
27
|
from .. import __version__
|
|
28
|
-
from ..colors import
|
|
28
|
+
from ..colors import ORANGE
|
|
29
29
|
from ..core import AsyncScanner, ModuleStatus, ScanContext, ScanEvent
|
|
30
30
|
from ..core.scanner import PREFETCH, SHARED_IP
|
|
31
31
|
from ..modules import all_modules
|
|
@@ -39,10 +39,10 @@ from .widgets import ActivityLog, MapPanel, SitemapTree, StatusPanel, TabBar, Ta
|
|
|
39
39
|
_BAR_WIDTH = 22
|
|
40
40
|
_BAR_DIM = "grey30"
|
|
41
41
|
|
|
42
|
-
#:
|
|
43
|
-
#:
|
|
44
|
-
#:
|
|
45
|
-
|
|
42
|
+
#: The status-bar version dot is shown only when a newer PyPI release is available
|
|
43
|
+
#: (`_version_status == "outdated"`; see net/version_check.py) — an orange `•`. The
|
|
44
|
+
#: initial/unknown and up-to-date states show no dot, keeping the footer quiet until
|
|
45
|
+
#: there's something to say.
|
|
46
46
|
|
|
47
47
|
#: below this terminal width the layout collapses to a single column (the fixed map
|
|
48
48
|
#: + Server panels become tabs, activity becomes a tab) — see _apply_narrow.
|
|
@@ -56,19 +56,29 @@ _PSEUDO_NAMES = frozenset(name for name, _ in _PSEUDO_TABS)
|
|
|
56
56
|
_PSEUDO_LABELS = {name: label for name, label in _PSEUDO_TABS}
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
#: Wappalyzer categories that name the CMS, in priority order. ``CMS`` is the reliable
|
|
60
|
+
#: signal (the broader "Content" *group* also covers non-CMS tools). ``Page builders``
|
|
61
|
+
#: is a fallback for standalone site builders that carry no ``CMS`` tag (Webflow, Wix,
|
|
62
|
+
#: Squarespace, Duda, Framer). It is checked *only after* ``CMS`` comes up empty, because
|
|
63
|
+
#: the same category also holds WordPress *plugins* that are not the CMS (Elementor, Divi,
|
|
64
|
+
#: WPBakery) — on those sites Wappalyzer tags WordPress ``CMS`` and the first pass wins.
|
|
65
|
+
_CMS_CATEGORIES = ("CMS", "Page builders")
|
|
66
|
+
|
|
67
|
+
|
|
59
68
|
def _cms_from_tech(data: object) -> tuple[str, str | None] | None:
|
|
60
69
|
"""The CMS (name, version) from the Tech result, or ``None`` if none detected.
|
|
61
70
|
|
|
62
71
|
The Tech result is a ``Sections`` of per-group ``Grid``s whose rows are
|
|
63
|
-
``[name, categories, confidence, version]`` (see ``modules/tech.py``). A CMS is
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
", "-joined string; version is ``"-"`` when unknown →
|
|
72
|
+
``[name, categories, confidence, version]`` (see ``modules/tech.py``). A CMS is any
|
|
73
|
+
tech Wappalyzer tags with a category in ``_CMS_CATEGORIES`` — tried in order, so a
|
|
74
|
+
real ``CMS`` hit always beats a ``Page builders`` one across the whole result.
|
|
75
|
+
``categories`` is a ", "-joined string; version is ``"-"`` when unknown → ``None``.
|
|
67
76
|
"""
|
|
68
|
-
for
|
|
69
|
-
for
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
for target in _CMS_CATEGORIES:
|
|
78
|
+
for section in data or []:
|
|
79
|
+
for name, categories, _confidence, version in section.data:
|
|
80
|
+
if target in [c.strip() for c in str(categories).split(",")]:
|
|
81
|
+
return name, (version if version and version != "-" else None)
|
|
72
82
|
return None
|
|
73
83
|
|
|
74
84
|
|
|
@@ -117,12 +127,12 @@ def _same_cms(left: str, right: str) -> bool:
|
|
|
117
127
|
def _detect_cms(tech_data: object, html: str | None) -> tuple[str, str | None] | None:
|
|
118
128
|
"""The CMS (name, version) for the Server panel, or ``None`` if nothing detected.
|
|
119
129
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
Sitefinity
|
|
124
|
-
|
|
125
|
-
|
|
130
|
+
``_cms_from_tech`` is the primary signal (Wappalyzer's ``CMS`` category, then a
|
|
131
|
+
``Page builders`` fallback — see there). The ``<meta name="generator">`` tag covers
|
|
132
|
+
the two cases it misses: a CMS Wappalyzer has no fingerprint for at all (e.g.
|
|
133
|
+
Sitefinity) → the generator is used outright; and a CMS it detects but can't version
|
|
134
|
+
→ the generator supplies the version, but only when it names the *same* product, so
|
|
135
|
+
an "Elementor 3.x" generator can't hijack a "WordPress" hit.
|
|
126
136
|
"""
|
|
127
137
|
tech = _cms_from_tech(tech_data)
|
|
128
138
|
generators = _generators(html)
|
|
@@ -169,11 +179,13 @@ class VersionChecked(Message):
|
|
|
169
179
|
"""Result of the background PyPI update check (see net/version_check.py).
|
|
170
180
|
|
|
171
181
|
``status`` is one of ``"unknown"`` / ``"latest"`` / ``"outdated"`` — it drives
|
|
172
|
-
the colour of the status-bar version dot.
|
|
182
|
+
the colour of the status-bar version dot. ``latest`` is the newer version string
|
|
183
|
+
when ``status == "outdated"`` (else None) — used for the Activity Log line.
|
|
173
184
|
"""
|
|
174
185
|
|
|
175
|
-
def __init__(self, status: str) -> None:
|
|
186
|
+
def __init__(self, status: str, latest: str | None) -> None:
|
|
176
187
|
self.status = status
|
|
188
|
+
self.latest = latest
|
|
177
189
|
super().__init__()
|
|
178
190
|
|
|
179
191
|
|
|
@@ -239,6 +251,12 @@ class WebScannerApp(App):
|
|
|
239
251
|
# background PyPI update check (see _check_version) — drives the colour of the
|
|
240
252
|
# status-bar version dot. "unknown" (blue) until the check answers.
|
|
241
253
|
self._version_status = "unknown"
|
|
254
|
+
# …and, when a newer release exists, an Activity Log line announced once after
|
|
255
|
+
# whichever finishes last: the scan or the check itself (the dot is not enough
|
|
256
|
+
# on its own — the log line spells out the upgrade command).
|
|
257
|
+
self._update_available: str | None = None
|
|
258
|
+
self._version_checked = False
|
|
259
|
+
self._update_announced = False
|
|
242
260
|
|
|
243
261
|
# ---- layout -----------------------------------------------------------
|
|
244
262
|
|
|
@@ -313,20 +331,43 @@ class WebScannerApp(App):
|
|
|
313
331
|
self._set_keybar(editing=editing)
|
|
314
332
|
|
|
315
333
|
async def _check_version(self) -> None:
|
|
316
|
-
status,
|
|
317
|
-
self.post_message(VersionChecked(status))
|
|
334
|
+
status, latest = await asyncio.to_thread(update_status, __version__)
|
|
335
|
+
self.post_message(VersionChecked(status, latest))
|
|
318
336
|
|
|
319
337
|
def on_version_checked(self, message: VersionChecked) -> None:
|
|
320
338
|
self._version_status = message.status
|
|
339
|
+
self._update_available = message.latest
|
|
340
|
+
self._version_checked = True
|
|
321
341
|
self._refresh_version()
|
|
342
|
+
self._maybe_announce_update()
|
|
343
|
+
|
|
344
|
+
def _maybe_announce_update(self) -> None:
|
|
345
|
+
"""Append the "update available" Activity Log line once, after the scan's own
|
|
346
|
+
closing line — so it lands as the last message. Gated on the scan being done
|
|
347
|
+
(otherwise it would jump ahead of the module lines) and on a newer release
|
|
348
|
+
actually existing; the check may finish before or after the scan, so both
|
|
349
|
+
on_version_checked and on_scan_finished call this."""
|
|
350
|
+
if (
|
|
351
|
+
self._update_announced
|
|
352
|
+
or self._scanning
|
|
353
|
+
or not self._version_checked
|
|
354
|
+
or self._update_available is None
|
|
355
|
+
):
|
|
356
|
+
return
|
|
357
|
+
self._update_announced = True
|
|
358
|
+
self.query_one("#activity", ActivityLog).add(
|
|
359
|
+
activity.update_available(self._update_available)
|
|
360
|
+
)
|
|
322
361
|
|
|
323
362
|
def _refresh_version(self) -> None:
|
|
324
363
|
"""Draw the version + update dot at the right of the footer, unless a scan is
|
|
325
364
|
in progress (then the progress bar owns that slot — restored on finish)."""
|
|
326
365
|
if self._scanning:
|
|
327
366
|
return
|
|
328
|
-
dot
|
|
329
|
-
|
|
367
|
+
# Only show the dot when an update is available (hide the initial blue /
|
|
368
|
+
# up-to-date green states) — a quiet footer until there's something to say.
|
|
369
|
+
dot = f" [{ORANGE}]•[/]" if self._version_status == "outdated" else ""
|
|
370
|
+
self.query_one("#progress", Static).update(f"v{__version__}{dot}")
|
|
330
371
|
|
|
331
372
|
# ---- scanning ---------------------------------------------------------
|
|
332
373
|
|
|
@@ -435,12 +476,13 @@ class WebScannerApp(App):
|
|
|
435
476
|
)
|
|
436
477
|
)
|
|
437
478
|
self._refresh_version()
|
|
479
|
+
self._maybe_announce_update()
|
|
438
480
|
|
|
439
481
|
# ---- progress line ----------------------------------------------------
|
|
440
482
|
|
|
441
483
|
def _update_progress(self) -> None:
|
|
442
484
|
"""Draw a determinate progress bar (blue = done, grey = remaining) with the
|
|
443
|
-
percentage
|
|
485
|
+
percentage in white, right-aligned on the footer row."""
|
|
444
486
|
if not self._scanning:
|
|
445
487
|
return
|
|
446
488
|
total = len(self.modules)
|
|
@@ -449,7 +491,7 @@ class WebScannerApp(App):
|
|
|
449
491
|
blue = self.current_theme.primary
|
|
450
492
|
bar = f"[{blue}]{'━' * filled}[/][{_BAR_DIM}]{'━' * (_BAR_WIDTH - filled)}[/]"
|
|
451
493
|
self.query_one("#progress", Static).update(
|
|
452
|
-
f"{bar} [white]{round(frac * 100)}%
|
|
494
|
+
f"{bar} [white]{round(frac * 100)}%[/]"
|
|
453
495
|
)
|
|
454
496
|
|
|
455
497
|
# ---- tab selection ----------------------------------------------------
|
|
@@ -670,11 +712,19 @@ class WebScannerApp(App):
|
|
|
670
712
|
def action_zoom_out(self) -> None:
|
|
671
713
|
self.query_one("#map", MapPanel).zoom_by(-1)
|
|
672
714
|
|
|
715
|
+
def _page_target(self):
|
|
716
|
+
"""The widget page-up/down should scroll. Usually the main panel, but the
|
|
717
|
+
Activity pseudo-tab (narrow only) shows the #activity RichLog full-height in
|
|
718
|
+
#main's place, so page keys must scroll that instead — #main is hidden there."""
|
|
719
|
+
if self._narrow and self.selected == "activity":
|
|
720
|
+
return self.query_one("#activity", ActivityLog)
|
|
721
|
+
return self.query_one("#main", VerticalScroll)
|
|
722
|
+
|
|
673
723
|
def action_scroll_main_up(self) -> None:
|
|
674
|
-
self.
|
|
724
|
+
self._page_target().scroll_page_up()
|
|
675
725
|
|
|
676
726
|
def action_scroll_main_down(self) -> None:
|
|
677
|
-
self.
|
|
727
|
+
self._page_target().scroll_page_down()
|
|
678
728
|
|
|
679
729
|
def on_input_submitted(self, message: Input.Submitted) -> None:
|
|
680
730
|
target = message.value.strip()
|
|
@@ -702,6 +752,10 @@ class WebScannerApp(App):
|
|
|
702
752
|
# Compact set — the full labels overflow a phone-width footer.
|
|
703
753
|
pairs = [("←/→", "Tab"), ("r", "Scan"), ("s", "Save"), ("q", "Quit")]
|
|
704
754
|
else:
|
|
705
|
-
|
|
755
|
+
# PgUp/Dn scrolls #main — only shown wide; the narrow footer has no room.
|
|
756
|
+
pairs = [
|
|
757
|
+
("q", "Quit"), ("←/→", "Tab"), ("Pg↑/↓", "Scroll"),
|
|
758
|
+
("r", "Rescan"), ("s", "Save"), ("esc", "Edit domain"),
|
|
759
|
+
]
|
|
706
760
|
text = " ".join(f"[b {c}]{k}[/] {label}" for k, label in pairs)
|
|
707
761
|
self.query_one("#keybar", Static).update(text)
|
|
@@ -37,8 +37,9 @@ TAB_HEADERS: dict[str, tuple[str, str]] = {
|
|
|
37
37
|
MAX_KEY_WIDTH = 34
|
|
38
38
|
|
|
39
39
|
# Tabs whose key column is smart-cased (Title Case, acronyms kept upper) instead of
|
|
40
|
-
# ALL CAPS. dns
|
|
41
|
-
|
|
40
|
+
# ALL CAPS. dns is smart too — its keys are mostly record-type acronyms (kept upper via
|
|
41
|
+
# _ACRONYMS), but derived rows read better Title-cased ("Email Spoofing", "CNAME (www)").
|
|
42
|
+
_SMART_LABEL_TABS = {"dns", "whois", "ssl", "headers"}
|
|
42
43
|
|
|
43
44
|
# Tokens that render upper-case rather than Title-cased. Lowercase keys. Only needs
|
|
44
45
|
# the acronyms that actually appear as key tokens in the smart-cased tabs — NOT an
|
|
@@ -49,6 +50,8 @@ _ACRONYMS: frozenset[str] = frozenset({
|
|
|
49
50
|
"id", "url", "iana", "whois", "dnssec",
|
|
50
51
|
# ssl
|
|
51
52
|
"cn", "san", "ssl", "tls",
|
|
53
|
+
# dns record types (keep upper on the smart-cased dns tab)
|
|
54
|
+
"a", "aaaa", "ns", "cname", "soa", "mx", "txt", "caa", "ds", "dnskey",
|
|
52
55
|
# headers / general web acronyms
|
|
53
56
|
"xss", "csp", "hsts", "cors", "www", "ua", "md5", "ip", "dns",
|
|
54
57
|
"http", "https", "uri", "api", "spf", "dkim", "dmarc",
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|