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.
Files changed (43) hide show
  1. {web_scanner-2.2.2 → web_scanner-2.2.3}/PKG-INFO +19 -7
  2. {web_scanner-2.2.2 → web_scanner-2.2.3}/README.md +18 -6
  3. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/__init__.py +1 -1
  4. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/dns.py +12 -1
  5. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/activity.py +8 -1
  6. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/app.py +84 -30
  7. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/tables.py +5 -2
  8. {web_scanner-2.2.2 → web_scanner-2.2.3}/.gitignore +0 -0
  9. {web_scanner-2.2.2 → web_scanner-2.2.3}/LICENSE +0 -0
  10. {web_scanner-2.2.2 → web_scanner-2.2.3}/app.py +0 -0
  11. {web_scanner-2.2.2 → web_scanner-2.2.3}/pyproject.toml +0 -0
  12. {web_scanner-2.2.2 → web_scanner-2.2.3}/requirements.txt +0 -0
  13. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/__main__.py +0 -0
  14. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/cli.py +0 -0
  15. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/colors.py +0 -0
  16. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/core/__init__.py +0 -0
  17. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/core/context.py +0 -0
  18. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/core/models.py +0 -0
  19. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/core/module.py +0 -0
  20. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/core/scanner.py +0 -0
  21. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/helpers.py +0 -0
  22. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/__init__.py +0 -0
  23. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/headers.py +0 -0
  24. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/links.py +0 -0
  25. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/security.py +0 -0
  26. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/seo.py +0 -0
  27. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/sitemap.py +0 -0
  28. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/ssl.py +0 -0
  29. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/subdomains.py +0 -0
  30. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/tech.py +0 -0
  31. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/modules/whois.py +0 -0
  32. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/__init__.py +0 -0
  33. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/agents.py +0 -0
  34. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/data/public_suffix_list.dat +0 -0
  35. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/http.py +0 -0
  36. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/psl.py +0 -0
  37. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/net/version_check.py +0 -0
  38. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/__init__.py +0 -0
  39. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/app.tcss +0 -0
  40. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/data/countries.json +0 -0
  41. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/export.py +0 -0
  42. {web_scanner-2.2.2 → web_scanner-2.2.3}/webscanner/ui/widgets.py +0 -0
  43. {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.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
- ![WebScanner — DNS tab](https://raw.githubusercontent.com/iamramizk/web-scanner/main/.github/screenshot-v2-1.png)
73
-
74
- ![WebScanner — Security tab](https://raw.githubusercontent.com/iamramizk/web-scanner/main/.github/screenshot-v2-2.png)
72
+ ![WebScanner — DNS tab, with the responsive narrow layout inset](https://raw.githubusercontent.com/iamramizk/web-scanner/main/.github/screenshot-v2-2-2.png)
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
- Press `s` inside the app to save every tab to CSV. When installed, results are written to a
178
- `./<domain>_<timestamp>/` folder in your current directory.
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
- ![WebScanner — DNS tab](https://raw.githubusercontent.com/iamramizk/web-scanner/main/.github/screenshot-v2-1.png)
23
-
24
- ![WebScanner — Security tab](https://raw.githubusercontent.com/iamramizk/web-scanner/main/.github/screenshot-v2-2.png)
22
+ ![WebScanner — DNS tab, with the responsive narrow layout inset](https://raw.githubusercontent.com/iamramizk/web-scanner/main/.github/screenshot-v2-2-2.png)
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
- Press `s` inside the app to save every tab to CSV. When installed, results are written to a
128
- `./<domain>_<timestamp>/` folder in your current directory.
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 |
@@ -1,3 +1,3 @@
1
1
  """web-scanner v2 — async website reconnaissance TUI package."""
2
2
 
3
- __version__ = "2.2.2"
3
+ __version__ = "2.2.3"
@@ -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 BLUE, GREEN, ORANGE
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
- #: colour of the status-bar version dot per update state (see net/version_check.py):
43
- #: blue until the background check answers / if it couldn't, green = up to date,
44
- #: orange = a newer PyPI release is available.
45
- _DOT_COLOURS = {"unknown": BLUE, "latest": GREEN, "outdated": ORANGE}
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
- any tech Wappalyzer tags with the ``CMS`` category the reliable signal (the
65
- broader "Content" *group* also covers non-CMS tools). ``categories`` is a
66
- ", "-joined string; version is ``"-"`` when unknown → returned as ``None``.
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 section in data or []:
69
- for name, categories, _confidence, version in section.data:
70
- if "CMS" in [c.strip() for c in str(categories).split(",")]:
71
- return name, (version if version and version != "-" else None)
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
- Wappalyzer's ``CMS`` category is the primary signal — it's curated, so it won't
121
- mistake a page builder or analytics tag for the CMS. The ``<meta name="generator">``
122
- tag covers the two cases it misses: a CMS Wappalyzer has no fingerprint for (e.g.
123
- Sitefinity, Webflow) → the generator is used outright; and a CMS it detects but
124
- can't version → the generator supplies the version, but only when it names the
125
- *same* product, so an "Elementor 3.x" generator can't hijack a "WordPress" hit.
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, _latest = await asyncio.to_thread(update_status, __version__)
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 = _DOT_COLOURS.get(self._version_status, BLUE)
329
- self.query_one("#progress", Static).update(f"v{__version__} [{dot}]•[/]")
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 and n/total count in white, right-aligned on the footer row."""
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)}% {self.completed}/{total}[/]"
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.query_one("#main", VerticalScroll).scroll_page_up()
724
+ self._page_target().scroll_page_up()
675
725
 
676
726
  def action_scroll_main_down(self) -> None:
677
- self.query_one("#main", VerticalScroll).scroll_page_down()
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
- pairs = [("q", "Quit"), ("←/→", "Tab"), ("r", "Rescan"), ("s", "Save"), ("esc", "Edit domain")]
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 stays "upper" — its keys are record types (AAAA/CNAME/DNSKEY/DMARC).
41
- _SMART_LABEL_TABS = {"whois", "ssl", "headers"}
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