web-scanner 2.2.1__tar.gz → 2.2.2__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.1 → web_scanner-2.2.2}/PKG-INFO +1 -1
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/__init__.py +1 -1
- web_scanner-2.2.2/webscanner/cli.py +36 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/colors.py +3 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/core/models.py +6 -2
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/sitemap.py +39 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/version_check.py +24 -6
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/activity.py +29 -9
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/app.py +48 -31
- web_scanner-2.2.1/webscanner/cli.py +0 -24
- {web_scanner-2.2.1 → web_scanner-2.2.2}/.gitignore +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/LICENSE +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/README.md +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/app.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/pyproject.toml +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/requirements.txt +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/__main__.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/core/__init__.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/core/context.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/core/module.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/core/scanner.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/helpers.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/__init__.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/dns.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/headers.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/links.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/security.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/seo.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/ssl.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/subdomains.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/tech.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/modules/whois.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/__init__.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/agents.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/data/public_suffix_list.dat +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/http.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/net/psl.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/__init__.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/app.tcss +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/data/countries.json +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/export.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/tables.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/webscanner/ui/widgets.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.2}/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.2
|
|
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
|
|
@@ -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
|
|
74
|
-
|
|
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
|
|
|
@@ -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
|
|
66
|
-
"""The
|
|
65
|
+
def update_status(current: str) -> tuple[str, str | None]:
|
|
66
|
+
"""The update state as ``(status, latest)``:
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
from ..colors import
|
|
32
|
+
from ..colors import 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
|
-
|
|
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
|
-
|
|
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:
|
|
@@ -338,12 +364,6 @@ def waf(result: ModuleResult) -> str | None:
|
|
|
338
364
|
return f"{line} · actively blocking." if blocked else f"{line}."
|
|
339
365
|
|
|
340
366
|
|
|
341
|
-
def update_available(latest: str) -> str:
|
|
342
|
-
"""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."""
|
|
344
|
-
return f"Update: [{BLUE}]v{_esc(latest)} available[/] — run `pipx upgrade web-scanner`."
|
|
345
|
-
|
|
346
|
-
|
|
347
367
|
def overall(completed: int, failed: int, total: int, seconds: float) -> str:
|
|
348
368
|
"""The closing line. ``seconds`` is wall-clock for the whole scan — modules run
|
|
349
369
|
concurrently, so summing their durations would overstate it several-fold."""
|
|
@@ -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 BLUE, GREEN, 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
|
|
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
|
+
#: 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}
|
|
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
|
|
@@ -133,6 +139,20 @@ def _detect_cms(tech_data: object, html: str | None) -> tuple[str, str | None] |
|
|
|
133
139
|
return tech
|
|
134
140
|
|
|
135
141
|
|
|
142
|
+
def _sitemap_subtitle(root: object) -> str:
|
|
143
|
+
"""The Sitemap tab's ``#main`` subtitle: ``"12 pages • 3 assets"`` (pages vs.
|
|
144
|
+
assets split purely by URL extension — see ``sitemap._is_asset``). Assets are
|
|
145
|
+
only shown when present; falls back to the raw total if the split is missing."""
|
|
146
|
+
pages = getattr(root, "pages", None)
|
|
147
|
+
assets = getattr(root, "assets", None) or 0
|
|
148
|
+
if pages is None: # older/other tree without the split — show the plain count
|
|
149
|
+
return f"{getattr(root, 'total', 0) or 0} Total"
|
|
150
|
+
parts = [f"{pages} {'page' if pages == 1 else 'pages'}"]
|
|
151
|
+
if assets:
|
|
152
|
+
parts.append(f"{assets} {'asset' if assets == 1 else 'assets'}")
|
|
153
|
+
return " • ".join(parts)
|
|
154
|
+
|
|
155
|
+
|
|
136
156
|
class ScanProgress(Message):
|
|
137
157
|
"""A ScanEvent surfaced onto the Textual message pump."""
|
|
138
158
|
|
|
@@ -146,10 +166,14 @@ class ScanFinished(Message):
|
|
|
146
166
|
|
|
147
167
|
|
|
148
168
|
class VersionChecked(Message):
|
|
149
|
-
"""Result of the background PyPI update check (see net/version_check.py).
|
|
169
|
+
"""Result of the background PyPI update check (see net/version_check.py).
|
|
170
|
+
|
|
171
|
+
``status`` is one of ``"unknown"`` / ``"latest"`` / ``"outdated"`` — it drives
|
|
172
|
+
the colour of the status-bar version dot.
|
|
173
|
+
"""
|
|
150
174
|
|
|
151
|
-
def __init__(self,
|
|
152
|
-
self.
|
|
175
|
+
def __init__(self, status: str) -> None:
|
|
176
|
+
self.status = status
|
|
153
177
|
super().__init__()
|
|
154
178
|
|
|
155
179
|
|
|
@@ -212,11 +236,9 @@ class WebScannerApp(App):
|
|
|
212
236
|
# last result rendered into the sitemap Tree, so switching tabs doesn't
|
|
213
237
|
# rebuild (and re-collapse) it every visit.
|
|
214
238
|
self._tree_result = None
|
|
215
|
-
# background PyPI update check (see _check_version) —
|
|
216
|
-
#
|
|
217
|
-
self.
|
|
218
|
-
self._version_checked = False
|
|
219
|
-
self._update_announced = False
|
|
239
|
+
# background PyPI update check (see _check_version) — drives the colour of the
|
|
240
|
+
# status-bar version dot. "unknown" (blue) until the check answers.
|
|
241
|
+
self._version_status = "unknown"
|
|
220
242
|
|
|
221
243
|
# ---- layout -----------------------------------------------------------
|
|
222
244
|
|
|
@@ -246,6 +268,9 @@ class WebScannerApp(App):
|
|
|
246
268
|
self.query_one("#tabs", TabBar).set_selected(self.selected)
|
|
247
269
|
# Establish the layout (wide vs narrow) before the first paint / scan.
|
|
248
270
|
self._apply_narrow(self.size.width < _NARROW_MAX)
|
|
271
|
+
# Show the version + (blue) update dot straight away; the dot recolours once
|
|
272
|
+
# the background check answers, and the progress bar reclaims the slot mid-scan.
|
|
273
|
+
self._refresh_version()
|
|
249
274
|
# own group: the scan worker below is exclusive=True, which cancels every
|
|
250
275
|
# other worker in its group — a shared/default group would kill this one.
|
|
251
276
|
self.run_worker(
|
|
@@ -288,26 +313,20 @@ class WebScannerApp(App):
|
|
|
288
313
|
self._set_keybar(editing=editing)
|
|
289
314
|
|
|
290
315
|
async def _check_version(self) -> None:
|
|
291
|
-
|
|
292
|
-
self.post_message(VersionChecked(
|
|
316
|
+
status, _latest = await asyncio.to_thread(update_status, __version__)
|
|
317
|
+
self.post_message(VersionChecked(status))
|
|
293
318
|
|
|
294
319
|
def on_version_checked(self, message: VersionChecked) -> None:
|
|
295
|
-
self.
|
|
296
|
-
self.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
or self._scanning
|
|
303
|
-
or not self._version_checked
|
|
304
|
-
or self._update_available is None
|
|
305
|
-
):
|
|
320
|
+
self._version_status = message.status
|
|
321
|
+
self._refresh_version()
|
|
322
|
+
|
|
323
|
+
def _refresh_version(self) -> None:
|
|
324
|
+
"""Draw the version + update dot at the right of the footer, unless a scan is
|
|
325
|
+
in progress (then the progress bar owns that slot — restored on finish)."""
|
|
326
|
+
if self._scanning:
|
|
306
327
|
return
|
|
307
|
-
self.
|
|
308
|
-
self.query_one("#
|
|
309
|
-
activity.update_available(self._update_available)
|
|
310
|
-
)
|
|
328
|
+
dot = _DOT_COLOURS.get(self._version_status, BLUE)
|
|
329
|
+
self.query_one("#progress", Static).update(f"v{__version__} [{dot}]•[/]")
|
|
311
330
|
|
|
312
331
|
# ---- scanning ---------------------------------------------------------
|
|
313
332
|
|
|
@@ -415,8 +434,7 @@ class WebScannerApp(App):
|
|
|
415
434
|
self.completed, self.failed, total, time.monotonic() - self._scan_start
|
|
416
435
|
)
|
|
417
436
|
)
|
|
418
|
-
self.
|
|
419
|
-
self._maybe_announce_update()
|
|
437
|
+
self._refresh_version()
|
|
420
438
|
|
|
421
439
|
# ---- progress line ----------------------------------------------------
|
|
422
440
|
|
|
@@ -542,9 +560,8 @@ class WebScannerApp(App):
|
|
|
542
560
|
if self._tree_result is not result:
|
|
543
561
|
tree.populate(result.data)
|
|
544
562
|
self._tree_result = result
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
self.query_one("#main").border_subtitle = f"{total} Total"
|
|
563
|
+
if result.data.total is not None:
|
|
564
|
+
self.query_one("#main").border_subtitle = _sitemap_subtitle(result.data)
|
|
548
565
|
elif result.status is ModuleStatus.FAILED:
|
|
549
566
|
self._set_main(f"[red]failed:[/] {result.error}")
|
|
550
567
|
elif result.status is ModuleStatus.EMPTY:
|
|
@@ -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
|
|
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
|