web-scanner 2.2.1__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 (44) hide show
  1. {web_scanner-2.2.1 → web_scanner-2.2.3}/PKG-INFO +19 -7
  2. {web_scanner-2.2.1 → web_scanner-2.2.3}/README.md +18 -6
  3. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/__init__.py +1 -1
  4. web_scanner-2.2.3/webscanner/cli.py +36 -0
  5. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/colors.py +3 -0
  6. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/core/models.py +6 -2
  7. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/dns.py +12 -1
  8. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/sitemap.py +39 -0
  9. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/version_check.py +24 -6
  10. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/activity.py +30 -3
  11. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/app.py +101 -30
  12. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/tables.py +5 -2
  13. web_scanner-2.2.1/webscanner/cli.py +0 -24
  14. {web_scanner-2.2.1 → web_scanner-2.2.3}/.gitignore +0 -0
  15. {web_scanner-2.2.1 → web_scanner-2.2.3}/LICENSE +0 -0
  16. {web_scanner-2.2.1 → web_scanner-2.2.3}/app.py +0 -0
  17. {web_scanner-2.2.1 → web_scanner-2.2.3}/pyproject.toml +0 -0
  18. {web_scanner-2.2.1 → web_scanner-2.2.3}/requirements.txt +0 -0
  19. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/__main__.py +0 -0
  20. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/core/__init__.py +0 -0
  21. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/core/context.py +0 -0
  22. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/core/module.py +0 -0
  23. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/core/scanner.py +0 -0
  24. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/helpers.py +0 -0
  25. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/__init__.py +0 -0
  26. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/headers.py +0 -0
  27. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/links.py +0 -0
  28. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/security.py +0 -0
  29. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/seo.py +0 -0
  30. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/ssl.py +0 -0
  31. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/subdomains.py +0 -0
  32. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/tech.py +0 -0
  33. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/whois.py +0 -0
  34. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/__init__.py +0 -0
  35. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/agents.py +0 -0
  36. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/data/public_suffix_list.dat +0 -0
  37. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/http.py +0 -0
  38. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/psl.py +0 -0
  39. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/__init__.py +0 -0
  40. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/app.tcss +0 -0
  41. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/data/countries.json +0 -0
  42. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/export.py +0 -0
  43. {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/widgets.py +0 -0
  44. {web_scanner-2.2.1 → 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.1
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.1"
3
+ __version__ = "2.2.3"
@@ -0,0 +1,36 @@
1
+ """Console entry point for the ``webscan`` command.
2
+
3
+ webscan example.com
4
+ python -m webscanner example.com
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+
12
+ from webscanner.helpers import is_valid_url
13
+ from webscanner.ui.app import WebScannerApp
14
+
15
+
16
+ def main() -> None:
17
+ target = sys.argv[1].strip() if len(sys.argv) > 1 else None
18
+ if target and not is_valid_url(target):
19
+ print(f"[!] Not a valid target: {target}")
20
+ raise SystemExit(1)
21
+ WebScannerApp(target).run()
22
+ # Textual has already restored the terminal by the time run() returns.
23
+ # Quitting mid-scan cancels the scan worker's coroutine, but the blocking
24
+ # work it dispatched via asyncio.to_thread (requests/socket/pydig/Wappalyzer,
25
+ # each up to ~12–30s of timeout) keeps running in asyncio's default thread
26
+ # pool. Those are non-daemon threads, so concurrent.futures' atexit handler
27
+ # joins them on interpreter shutdown and the process hangs after the UI is
28
+ # gone. os._exit bypasses that join and terminates now — safe here: the
29
+ # terminal is restored and there is no post-scan state left to flush.
30
+ sys.stdout.flush()
31
+ sys.stderr.flush()
32
+ os._exit(0)
33
+
34
+
35
+ if __name__ == "__main__":
36
+ main()
@@ -5,6 +5,9 @@ RED = "#FF6C64"
5
5
  #: matches the app's primary theme blue ($primary, textual-dark) — progress bar,
6
6
  #: selected-tab chip, keybar hints.
7
7
  BLUE = "#0178D4"
8
+ #: update-available signal — the status-bar version dot when a newer PyPI release
9
+ #: exists (green = up to date, blue = not yet checked / couldn't check).
10
+ ORANGE = "#F5A623"
8
11
  #: muted value/secondary text — matches the unselected-tab colour
9
12
  #: ($text-muted = white @ 60% over the dark theme bg).
10
13
  MUTED = "#9D9D9D"
@@ -70,13 +70,17 @@ class TreeNode:
70
70
  site's URL-path hierarchy (``/blog`` → ``/blog/post-1`` …). A node is a branch
71
71
  (expandable folder) iff it has ``children``; otherwise it's a leaf (a page). The
72
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.
73
+ is the count of URLs behind the tree, split into ``pages`` + ``assets`` (also
74
+ root-only), a purely parsed classification by file extension see
75
+ ``sitemap._is_asset``; shown in the panel subtitle. ``url`` is the full page URL a
76
+ node stands for (set on leaves), so the UI can make it clickable.
75
77
  """
76
78
 
77
79
  label: str
78
80
  children: list["TreeNode"] = field(default_factory=list)
79
81
  total: int | None = None
82
+ pages: int | None = None
83
+ assets: int | None = None
80
84
  url: str | None = None
81
85
 
82
86
 
@@ -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]
@@ -37,6 +37,32 @@ MAX_URLS = 10000
37
37
  #: how deep a chain of nested <sitemapindex> files we follow
38
38
  MAX_DEPTH = 6
39
39
 
40
+ #: File extensions that mark a sitemap ``<loc>`` as a downloadable **asset**
41
+ #: (image / media / document / archive / static file) rather than a page.
42
+ #: Classification is *pure parsing* — read off the URL path, never fetched.
43
+ #: Anything not listed here (no extension, ``.html``/``.php``, a dynamic route,
44
+ #: a trailing slash) counts as a page, so the bias is toward "page" and odd
45
+ #: dynamic URLs aren't mislabelled.
46
+ ASSET_EXTENSIONS = frozenset({
47
+ # images
48
+ "jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico",
49
+ "tif", "tiff", "avif", "heic", "heif",
50
+ # audio / video
51
+ "mp3", "mp4", "wav", "ogg", "oga", "webm", "mov", "avi", "mkv",
52
+ "flv", "m4a", "m4v", "wmv", "aac", "flac",
53
+ # documents
54
+ "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods",
55
+ "odp", "rtf", "csv", "tsv", "epub",
56
+ # archives
57
+ "zip", "gz", "tgz", "tar", "rar", "7z", "bz2", "xz",
58
+ # web / static assets
59
+ "css", "js", "mjs", "map", "wasm", "rss", "atom",
60
+ # fonts
61
+ "woff", "woff2", "ttf", "otf", "eot",
62
+ # binaries / packages
63
+ "dmg", "exe", "apk", "pkg", "iso", "bin", "deb", "rpm", "msi",
64
+ })
65
+
40
66
 
41
67
  class SitemapModule(ScanModule):
42
68
  name = "sitemap"
@@ -158,6 +184,17 @@ def _localname(tag: str) -> str:
158
184
  return tag.rsplit("}", 1)[-1].lower()
159
185
 
160
186
 
187
+ def _is_asset(url: str) -> bool:
188
+ """True if `url`'s path names a downloadable asset (by file extension) rather
189
+ than a page. Pure parsing — no request is ever made. The extension is taken
190
+ from the last path segment only (query/fragment ignored); a missing or
191
+ unknown extension, or a trailing slash, counts as a page."""
192
+ last = urlparse(url).path.rsplit("/", 1)[-1]
193
+ if "." not in last:
194
+ return False
195
+ return last.rsplit(".", 1)[-1].lower() in ASSET_EXTENSIONS
196
+
197
+
161
198
  def _segments(url: str, include_host: bool) -> list[str]:
162
199
  """Path segments for `url` as tree labels: ``/blog``, ``/post`` … An empty path
163
200
  (the homepage) yields ``[]`` — it *is* the ``/`` root, not a child of it. When
@@ -196,6 +233,8 @@ def _url_tree(urls: list[str], truncated: bool) -> TreeNode:
196
233
  if truncated:
197
234
  root.children.append(TreeNode(label=f"… (truncated at {MAX_URLS} URLs)"))
198
235
  root.total = len(urls)
236
+ root.assets = sum(1 for u in urls if _is_asset(u))
237
+ root.pages = root.total - root.assets
199
238
  _sort_tree(root)
200
239
  return root
201
240
 
@@ -62,11 +62,17 @@ def _write_cache(latest: str) -> None:
62
62
  path.write_text(json.dumps({"latest": latest, "checked_at": time.time()}))
63
63
 
64
64
 
65
- def check_for_update(current: str) -> str | None:
66
- """The latest PyPI version if newer than ``current``, else ``None``.
65
+ def update_status(current: str) -> tuple[str, str | None]:
66
+ """The update state as ``(status, latest)``:
67
67
 
68
- Silent on any failure network, parse, or filesystem — since a broken update
69
- check must never be visible as a scan problem.
68
+ - ``("outdated", latest)``a newer PyPI release exists;
69
+ - ``("latest", None)`` confirmed up to date;
70
+ - ``("unknown", None)`` — couldn't check (network, parse, or filesystem failure).
71
+
72
+ The ``unknown`` case is kept distinct from ``latest`` on purpose: the status-bar
73
+ dot shows green only for a *confirmed* up-to-date check, and stays neutral (blue)
74
+ when the check couldn't complete — a broken update check must never look like a
75
+ scan problem, nor falsely claim "up to date".
70
76
  """
71
77
  try:
72
78
  latest = _cached_latest()
@@ -76,6 +82,18 @@ def check_for_update(current: str) -> str | None:
76
82
  if latest is None:
77
83
  latest = _fetch_latest()
78
84
  _write_cache(latest)
79
- return latest if _parse(latest) > _parse(current) else None
80
85
  except Exception: # noqa: BLE001
81
- return None
86
+ return "unknown", None
87
+ if _parse(latest) > _parse(current):
88
+ return "outdated", latest
89
+ return "latest", None
90
+
91
+
92
+ def check_for_update(current: str) -> str | None:
93
+ """The latest PyPI version if newer than ``current``, else ``None``.
94
+
95
+ Silent on any failure — network, parse, or filesystem — since a broken update
96
+ check must never be visible as a scan problem.
97
+ """
98
+ status, latest = update_status(current)
99
+ return latest if status == "outdated" else None
@@ -25,9 +25,12 @@ from __future__ import annotations
25
25
  import re
26
26
  from typing import Any, Callable
27
27
 
28
+ from urllib.parse import urlparse
29
+
28
30
  from rich.markup import escape
29
31
 
30
32
  from ..colors import BLUE, GREEN, RED
33
+ from ..net.psl import registrable_domain
31
34
  from ..core.context import ScanContext
32
35
  from ..core.models import ModuleResult, ModuleStatus, ScanEvent
33
36
  from ..core.scanner import PREFETCH, SHARED_IP
@@ -129,6 +132,20 @@ def _dns(result: ModuleResult) -> str:
129
132
  return f"{line} · {', '.join(auth)}." if auth else f"{line}."
130
133
 
131
134
 
135
+ def _registrar_root(url: str | None) -> str:
136
+ """Registrar's registrable domain from its WHOIS ``registrar_url``.
137
+
138
+ ``http://www.hostinger.com`` → ``hostinger.com``. Tolerates a scheme-less
139
+ value (``www.hostinger.com``) — ``urlparse`` puts that in ``path``, not
140
+ ``netloc``, so fall back to the whole string as the host. Returns "" when
141
+ nothing usable (no URL, or no registrable domain resolves)."""
142
+ if not url:
143
+ return ""
144
+ parsed = urlparse(url.strip())
145
+ host = parsed.netloc or parsed.path
146
+ return registrable_domain(host.split("/")[0].split("@")[-1])
147
+
148
+
132
149
  def _whois(result: ModuleResult) -> str:
133
150
  data = result.data or {}
134
151
  if not data:
@@ -136,7 +153,11 @@ def _whois(result: ModuleResult) -> str:
136
153
  if (note := _note(data)) is not None:
137
154
  return f"{_DONE}. {_sentence(note)}"
138
155
  bits = []
139
- if registrar := data.get("registrar"):
156
+ # Prefer the registrar's root domain (registrable eTLD+1 of its URL) over its
157
+ # legal name — "hostinger.com" reads truer than "HOSTINGER operations, UAB".
158
+ # Fall back to the name when there's no URL (sparse ccTLD output).
159
+ registrar = _registrar_root(data.get("registrar_url")) or data.get("registrar")
160
+ if registrar:
140
161
  # Unlabelled: the Whois prefix and the "expires" half make it obvious enough,
141
162
  # and the 10 chars "Registrar " costs are better spent on the name itself.
142
163
  bits.append(_esc(registrar))
@@ -237,7 +258,12 @@ def _sitemap(result: ModuleResult) -> str:
237
258
  return f"{_DONE}. No sitemap found."
238
259
  total = root.total or 0
239
260
  suffix = " (truncated)" if total >= MAX_URLS else ""
240
- return f"{_DONE}. {_plural(total, 'URL')} found{suffix}."
261
+ assets = root.assets or 0
262
+ pages = root.pages if root.pages is not None else total
263
+ tally = _plural(pages, "page")
264
+ if assets:
265
+ tally += f", {_plural(assets, 'asset')}"
266
+ return f"{_DONE}. {tally} found{suffix}."
241
267
 
242
268
 
243
269
  def _links(result: ModuleResult) -> str:
@@ -340,7 +366,8 @@ def waf(result: ModuleResult) -> str | None:
340
366
 
341
367
  def update_available(latest: str) -> str:
342
368
  """A newer release exists on PyPI. Emitted once, after the scan's own closing
343
- line, only when a newer version was actually found — see net/version_check.py."""
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."""
344
371
  return f"Update: [{BLUE}]v{_esc(latest)} available[/] — run `pipx upgrade web-scanner`."
345
372
 
346
373
 
@@ -25,10 +25,11 @@ 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 ORANGE
28
29
  from ..core import AsyncScanner, ModuleStatus, ScanContext, ScanEvent
29
30
  from ..core.scanner import PREFETCH, SHARED_IP
30
31
  from ..modules import all_modules
31
- from ..net.version_check import check_for_update
32
+ from ..net.version_check import update_status
32
33
  from . import activity
33
34
  from .export import export_csvs
34
35
  from .tables import UNSET, render_result, render_status
@@ -38,6 +39,11 @@ from .widgets import ActivityLog, MapPanel, SitemapTree, StatusPanel, TabBar, Ta
38
39
  _BAR_WIDTH = 22
39
40
  _BAR_DIM = "grey30"
40
41
 
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
+
41
47
  #: below this terminal width the layout collapses to a single column (the fixed map
42
48
  #: + Server panels become tabs, activity becomes a tab) — see _apply_narrow.
43
49
  _NARROW_MAX = 90
@@ -50,19 +56,29 @@ _PSEUDO_NAMES = frozenset(name for name, _ in _PSEUDO_TABS)
50
56
  _PSEUDO_LABELS = {name: label for name, label in _PSEUDO_TABS}
51
57
 
52
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
+
53
68
  def _cms_from_tech(data: object) -> tuple[str, str | None] | None:
54
69
  """The CMS (name, version) from the Tech result, or ``None`` if none detected.
55
70
 
56
71
  The Tech result is a ``Sections`` of per-group ``Grid``s whose rows are
57
- ``[name, categories, confidence, version]`` (see ``modules/tech.py``). A CMS is
58
- any tech Wappalyzer tags with the ``CMS`` category the reliable signal (the
59
- broader "Content" *group* also covers non-CMS tools). ``categories`` is a
60
- ", "-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``.
61
76
  """
62
- for section in data or []:
63
- for name, categories, _confidence, version in section.data:
64
- if "CMS" in [c.strip() for c in str(categories).split(",")]:
65
- 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)
66
82
  return None
67
83
 
68
84
 
@@ -111,12 +127,12 @@ def _same_cms(left: str, right: str) -> bool:
111
127
  def _detect_cms(tech_data: object, html: str | None) -> tuple[str, str | None] | None:
112
128
  """The CMS (name, version) for the Server panel, or ``None`` if nothing detected.
113
129
 
114
- Wappalyzer's ``CMS`` category is the primary signal — it's curated, so it won't
115
- mistake a page builder or analytics tag for the CMS. The ``<meta name="generator">``
116
- tag covers the two cases it misses: a CMS Wappalyzer has no fingerprint for (e.g.
117
- Sitefinity, Webflow) → the generator is used outright; and a CMS it detects but
118
- can't version → the generator supplies the version, but only when it names the
119
- *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.
120
136
  """
121
137
  tech = _cms_from_tech(tech_data)
122
138
  generators = _generators(html)
@@ -133,6 +149,20 @@ def _detect_cms(tech_data: object, html: str | None) -> tuple[str, str | None] |
133
149
  return tech
134
150
 
135
151
 
152
+ def _sitemap_subtitle(root: object) -> str:
153
+ """The Sitemap tab's ``#main`` subtitle: ``"12 pages • 3 assets"`` (pages vs.
154
+ assets split purely by URL extension — see ``sitemap._is_asset``). Assets are
155
+ only shown when present; falls back to the raw total if the split is missing."""
156
+ pages = getattr(root, "pages", None)
157
+ assets = getattr(root, "assets", None) or 0
158
+ if pages is None: # older/other tree without the split — show the plain count
159
+ return f"{getattr(root, 'total', 0) or 0} Total"
160
+ parts = [f"{pages} {'page' if pages == 1 else 'pages'}"]
161
+ if assets:
162
+ parts.append(f"{assets} {'asset' if assets == 1 else 'assets'}")
163
+ return " • ".join(parts)
164
+
165
+
136
166
  class ScanProgress(Message):
137
167
  """A ScanEvent surfaced onto the Textual message pump."""
138
168
 
@@ -146,9 +176,15 @@ class ScanFinished(Message):
146
176
 
147
177
 
148
178
  class VersionChecked(Message):
149
- """Result of the background PyPI update check (see net/version_check.py)."""
179
+ """Result of the background PyPI update check (see net/version_check.py).
150
180
 
151
- def __init__(self, latest: str | None) -> None:
181
+ ``status`` is one of ``"unknown"`` / ``"latest"`` / ``"outdated"`` it drives
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.
184
+ """
185
+
186
+ def __init__(self, status: str, latest: str | None) -> None:
187
+ self.status = status
152
188
  self.latest = latest
153
189
  super().__init__()
154
190
 
@@ -212,8 +248,12 @@ class WebScannerApp(App):
212
248
  # last result rendered into the sitemap Tree, so switching tabs doesn't
213
249
  # rebuild (and re-collapse) it every visit.
214
250
  self._tree_result = None
215
- # background PyPI update check (see _check_version) — announced once, after
216
- # whichever finishes last: the scan or the check itself.
251
+ # background PyPI update check (see _check_version) — drives the colour of the
252
+ # status-bar version dot. "unknown" (blue) until the check answers.
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).
217
257
  self._update_available: str | None = None
218
258
  self._version_checked = False
219
259
  self._update_announced = False
@@ -246,6 +286,9 @@ class WebScannerApp(App):
246
286
  self.query_one("#tabs", TabBar).set_selected(self.selected)
247
287
  # Establish the layout (wide vs narrow) before the first paint / scan.
248
288
  self._apply_narrow(self.size.width < _NARROW_MAX)
289
+ # Show the version + (blue) update dot straight away; the dot recolours once
290
+ # the background check answers, and the progress bar reclaims the slot mid-scan.
291
+ self._refresh_version()
249
292
  # own group: the scan worker below is exclusive=True, which cancels every
250
293
  # other worker in its group — a shared/default group would kill this one.
251
294
  self.run_worker(
@@ -288,15 +331,22 @@ class WebScannerApp(App):
288
331
  self._set_keybar(editing=editing)
289
332
 
290
333
  async def _check_version(self) -> None:
291
- latest = await asyncio.to_thread(check_for_update, __version__)
292
- self.post_message(VersionChecked(latest))
334
+ status, latest = await asyncio.to_thread(update_status, __version__)
335
+ self.post_message(VersionChecked(status, latest))
293
336
 
294
337
  def on_version_checked(self, message: VersionChecked) -> None:
338
+ self._version_status = message.status
295
339
  self._update_available = message.latest
296
340
  self._version_checked = True
341
+ self._refresh_version()
297
342
  self._maybe_announce_update()
298
343
 
299
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."""
300
350
  if (
301
351
  self._update_announced
302
352
  or self._scanning
@@ -309,6 +359,16 @@ class WebScannerApp(App):
309
359
  activity.update_available(self._update_available)
310
360
  )
311
361
 
362
+ def _refresh_version(self) -> None:
363
+ """Draw the version + update dot at the right of the footer, unless a scan is
364
+ in progress (then the progress bar owns that slot — restored on finish)."""
365
+ if self._scanning:
366
+ return
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}")
371
+
312
372
  # ---- scanning ---------------------------------------------------------
313
373
 
314
374
  def start_scan(self, target: str) -> None:
@@ -415,14 +475,14 @@ class WebScannerApp(App):
415
475
  self.completed, self.failed, total, time.monotonic() - self._scan_start
416
476
  )
417
477
  )
418
- self.query_one("#progress", Static).update(f"v{__version__}")
478
+ self._refresh_version()
419
479
  self._maybe_announce_update()
420
480
 
421
481
  # ---- progress line ----------------------------------------------------
422
482
 
423
483
  def _update_progress(self) -> None:
424
484
  """Draw a determinate progress bar (blue = done, grey = remaining) with the
425
- percentage and n/total count in white, right-aligned on the footer row."""
485
+ percentage in white, right-aligned on the footer row."""
426
486
  if not self._scanning:
427
487
  return
428
488
  total = len(self.modules)
@@ -431,7 +491,7 @@ class WebScannerApp(App):
431
491
  blue = self.current_theme.primary
432
492
  bar = f"[{blue}]{'━' * filled}[/][{_BAR_DIM}]{'━' * (_BAR_WIDTH - filled)}[/]"
433
493
  self.query_one("#progress", Static).update(
434
- f"{bar} [white]{round(frac * 100)}% {self.completed}/{total}[/]"
494
+ f"{bar} [white]{round(frac * 100)}%[/]"
435
495
  )
436
496
 
437
497
  # ---- tab selection ----------------------------------------------------
@@ -542,9 +602,8 @@ class WebScannerApp(App):
542
602
  if self._tree_result is not result:
543
603
  tree.populate(result.data)
544
604
  self._tree_result = result
545
- total = result.data.total
546
- if total is not None:
547
- self.query_one("#main").border_subtitle = f"{total} Total"
605
+ if result.data.total is not None:
606
+ self.query_one("#main").border_subtitle = _sitemap_subtitle(result.data)
548
607
  elif result.status is ModuleStatus.FAILED:
549
608
  self._set_main(f"[red]failed:[/] {result.error}")
550
609
  elif result.status is ModuleStatus.EMPTY:
@@ -653,11 +712,19 @@ class WebScannerApp(App):
653
712
  def action_zoom_out(self) -> None:
654
713
  self.query_one("#map", MapPanel).zoom_by(-1)
655
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
+
656
723
  def action_scroll_main_up(self) -> None:
657
- self.query_one("#main", VerticalScroll).scroll_page_up()
724
+ self._page_target().scroll_page_up()
658
725
 
659
726
  def action_scroll_main_down(self) -> None:
660
- self.query_one("#main", VerticalScroll).scroll_page_down()
727
+ self._page_target().scroll_page_down()
661
728
 
662
729
  def on_input_submitted(self, message: Input.Submitted) -> None:
663
730
  target = message.value.strip()
@@ -685,6 +752,10 @@ class WebScannerApp(App):
685
752
  # Compact set — the full labels overflow a phone-width footer.
686
753
  pairs = [("←/→", "Tab"), ("r", "Scan"), ("s", "Save"), ("q", "Quit")]
687
754
  else:
688
- 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
+ ]
689
760
  text = " ".join(f"[b {c}]{k}[/] {label}" for k, label in pairs)
690
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",
@@ -1,24 +0,0 @@
1
- """Console entry point for the ``webscan`` command.
2
-
3
- webscan example.com
4
- python -m webscanner example.com
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import sys
10
-
11
- from webscanner.helpers import is_valid_url
12
- from webscanner.ui.app import WebScannerApp
13
-
14
-
15
- def main() -> None:
16
- target = sys.argv[1].strip() if len(sys.argv) > 1 else None
17
- if target and not is_valid_url(target):
18
- print(f"[!] Not a valid target: {target}")
19
- raise SystemExit(1)
20
- WebScannerApp(target).run()
21
-
22
-
23
- if __name__ == "__main__":
24
- main()
File without changes
File without changes
File without changes
File without changes