web-scanner 2.1.2__tar.gz → 2.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {web_scanner-2.1.2 → web_scanner-2.1.3}/PKG-INFO +1 -1
  2. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/__init__.py +1 -1
  3. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/colors.py +3 -0
  4. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/links.py +7 -2
  5. web_scanner-2.1.3/webscanner/ui/activity.py +326 -0
  6. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/app.py +101 -8
  7. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/app.tcss +33 -1
  8. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/export.py +1 -19
  9. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/tables.py +10 -0
  10. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/widgets.py +116 -2
  11. {web_scanner-2.1.2 → web_scanner-2.1.3}/.gitignore +0 -0
  12. {web_scanner-2.1.2 → web_scanner-2.1.3}/LICENSE +0 -0
  13. {web_scanner-2.1.2 → web_scanner-2.1.3}/README.md +0 -0
  14. {web_scanner-2.1.2 → web_scanner-2.1.3}/app.py +0 -0
  15. {web_scanner-2.1.2 → web_scanner-2.1.3}/pyproject.toml +0 -0
  16. {web_scanner-2.1.2 → web_scanner-2.1.3}/requirements.txt +0 -0
  17. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/__main__.py +0 -0
  18. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/cli.py +0 -0
  19. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/core/__init__.py +0 -0
  20. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/core/context.py +0 -0
  21. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/core/models.py +0 -0
  22. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/core/module.py +0 -0
  23. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/core/scanner.py +0 -0
  24. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/helpers.py +0 -0
  25. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/__init__.py +0 -0
  26. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/dns.py +0 -0
  27. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/headers.py +0 -0
  28. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/security.py +0 -0
  29. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/seo.py +0 -0
  30. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/sitemap.py +0 -0
  31. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/ssl.py +0 -0
  32. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/subdomains.py +0 -0
  33. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/tech.py +0 -0
  34. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/modules/whois.py +0 -0
  35. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/net/__init__.py +0 -0
  36. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/net/data/public_suffix_list.dat +0 -0
  37. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/net/http.py +0 -0
  38. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/net/psl.py +0 -0
  39. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/__init__.py +0 -0
  40. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/data/countries.json +0 -0
  41. {web_scanner-2.1.2 → web_scanner-2.1.3}/webscanner/ui/worldmap.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: web-scanner
3
- Version: 2.1.2
3
+ Version: 2.1.3
4
4
  Summary: An async Textual TUI for website reconnaissance — DNS, WHOIS, TLS, security, tech-stack and SEO, with no paid APIs.
5
5
  Project-URL: Homepage, https://github.com/iamramizk/web-scanner
6
6
  Project-URL: Repository, https://github.com/iamramizk/web-scanner
@@ -1,3 +1,3 @@
1
1
  """web-scanner v2 — async website reconnaissance TUI package."""
2
2
 
3
- __version__ = "2.1.2"
3
+ __version__ = "2.1.3"
@@ -5,3 +5,6 @@ RED = "#FF6C64"
5
5
  #: muted value/secondary text — matches the unselected-tab colour
6
6
  #: ($text-muted = white @ 60% over the dark theme bg).
7
7
  MUTED = "#9D9D9D"
8
+ #: activity-log body text — one step down the white → MUTED ladder, so a log line
9
+ #: reads softer than a heading but stays well clear of the dim MUTED timestamp.
10
+ BODY = "#C6C6C6"
@@ -18,6 +18,11 @@ from ..core.models import Section, Sections
18
18
 
19
19
  _SKIP_PREFIXES = ("#", "javascript:", "mailto:", "tel:", "data:")
20
20
 
21
+ # Placeholder row shown when a section found nothing. Named so callers can tell
22
+ # "no links" from "one link" — the section is never empty, so len() can't.
23
+ EMPTY_INTERNAL = ("—", "no internal links found")
24
+ EMPTY_EXTERNAL = ("—", "no external links found")
25
+
21
26
 
22
27
  class LinksModule(ScanModule):
23
28
  name = "links"
@@ -31,8 +36,8 @@ class LinksModule(ScanModule):
31
36
  internal, external = await asyncio.to_thread(self._parse, ctx)
32
37
 
33
38
  return Sections([
34
- Section("Internal", internal or [("—", "no internal links found")], ("Link text", "URL")),
35
- Section("External", external or [("—", "no external links found")], ("Link text", "URL")),
39
+ Section("Internal", internal or [EMPTY_INTERNAL], ("Link text", "URL")),
40
+ Section("External", external or [EMPTY_EXTERNAL], ("Link text", "URL")),
36
41
  ])
37
42
 
38
43
  @staticmethod
@@ -0,0 +1,326 @@
1
+ """Activity-log lines — one short, informative sentence per scan event.
2
+
3
+ Pure ``ScanEvent -> str``: no Textual imports, so the whole line catalogue can be
4
+ exercised without an app, an event loop or the network. That matters because a
5
+ healthy live scan never reaches the branches most likely to be wrong (FAILED, EMPTY,
6
+ and the ``{"note": ...}`` "nothing to report" shape).
7
+
8
+ Grammar is fixed at ``<Label>: Complete. <facts>`` so the coloured verb always lands
9
+ in the same column. Only the verb and the odd warning word carry markup; the caller
10
+ (``ActivityLog.add``) supplies the timestamp and the body colour.
11
+
12
+ Lines are composed **at full length** — nothing here truncates to a character budget.
13
+ ``ActivityLog`` renders each line ``no_wrap`` with ``overflow="ellipsis"``, so a line
14
+ is cut at the panel edge and a wider terminal simply shows more of it. Keep the facts
15
+ front-loaded (the tail is what gets dropped), but don't pre-crop a value to make it
16
+ fit some assumed width.
17
+
18
+ The facts are the point — "DNS: Complete." tells you nothing the tab colour didn't.
19
+ Each summarizer digs the headline number out of its module's own return shape, which
20
+ is different for every module and booby-trapped in several (see the notes below).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from typing import Any, Callable
27
+
28
+ from rich.markup import escape
29
+
30
+ from ..colors import GREEN, RED
31
+ from ..core.context import ScanContext
32
+ from ..core.models import ModuleResult, ModuleStatus, ScanEvent
33
+ from ..core.scanner import PREFETCH
34
+ from ..modules import all_modules
35
+ from ..modules.dns import RECORD_TYPES
36
+ from ..modules.links import EMPTY_EXTERNAL, EMPTY_INTERNAL
37
+ from ..modules.sitemap import MAX_URLS
38
+ from .tables import _plain
39
+
40
+ #: module name -> tab label ("dns" -> "DNS"), so a line's prefix matches its tab.
41
+ _LABELS: dict[str, str] = {m.name: m.label for m in all_modules()}
42
+
43
+ _DONE = f"[{GREEN}]Complete[/]"
44
+ _ERROR = f"[{RED}]Error[/]"
45
+
46
+ #: repr(exc) -> ("TimeoutError", "'timed out'")
47
+ _ERROR_REPR = re.compile(r"^(\w+)\((.*)\)$", re.S)
48
+
49
+
50
+ # ---- helpers --------------------------------------------------------------
51
+
52
+
53
+ def _esc(value: Any) -> str:
54
+ """Collapse whitespace and escape rich markup.
55
+
56
+ Every externally-sourced value in a log line goes through here. Escaping is not
57
+ cosmetic: an unescaped ``[/]`` (from a registrar name, a Server header, a
58
+ repr(exc)) raises MarkupError inside ``RichLog.write``, which propagates out of
59
+ ``on_scan_progress`` and takes the handler down mid-scan.
60
+
61
+ Deliberately does **not** truncate: values are written in full and the widget
62
+ ellipsises at the panel edge, so a wide terminal shows the whole thing. A hard
63
+ cap here would crop "Web Address Registration Pty Ltd" on a 200-column display.
64
+ """
65
+ return escape(" ".join(str(value).split()))
66
+
67
+
68
+ def _plural(n: int, singular: str, plural: str | None = None) -> str:
69
+ return f"{n} {singular if n == 1 else (plural or singular + 's')}"
70
+
71
+
72
+ def _sentence(text: str) -> str:
73
+ """Upper-case the first letter and full-stop it, leaving the rest alone —
74
+ ``str.capitalize`` would flatten the modules' own wording (WHOIS -> Whois)."""
75
+ text = " ".join(str(text).split()).rstrip(".")
76
+ return f"{text[:1].upper()}{text[1:]}." if text else ""
77
+
78
+
79
+ def _note(data: Any) -> str | None:
80
+ """The module's own wording when it returned a lone ``{"note": ...}``.
81
+
82
+ whois, security's port table and seo all use this shape to say "nothing to
83
+ report". Echoing it verbatim keeps the wording with the module that owns it.
84
+ """
85
+ if isinstance(data, dict) and set(data) == {"note"}:
86
+ return str(data["note"])
87
+ return None
88
+
89
+
90
+ def _sections(result: ModuleResult) -> dict[str, Any]:
91
+ """A Sections result as {title: data}."""
92
+ return {section.title: section.data for section in result.data or []}
93
+
94
+
95
+ def _short_error(error: str | None) -> str:
96
+ """``repr(exc)`` boiled down to ``Type: message``.
97
+
98
+ Unwrapping the repr is formatting, not trimming — the message is kept whole and
99
+ the panel ellipsises whatever doesn't fit.
100
+ """
101
+ text = (error or "unknown error").strip()
102
+ if match := _ERROR_REPR.match(text):
103
+ name, args = match.group(1), match.group(2).strip().strip("'\"")
104
+ text = f"{name}: {args}" if args else name
105
+ return _esc(text)
106
+
107
+
108
+ def _date_only(value: Any) -> str:
109
+ """The date half of a whois timestamp — they arrive both as bare dates and as
110
+ ISO datetimes, so slicing [:10] is not safe."""
111
+ text = str(value or "").replace("T", " ").strip()
112
+ return text.split()[0] if text else ""
113
+
114
+
115
+ # ---- per-module summaries -------------------------------------------------
116
+
117
+
118
+ def _dns(result: ModuleResult) -> str:
119
+ # Keys are only present when non-empty, and DMARC/DKIM are folded in alongside
120
+ # the record types — count only the latter, then name the email auth found.
121
+ data = result.data or {}
122
+ if not data:
123
+ return f"{_DONE}. No records found."
124
+ types = [key for key in data if key in RECORD_TYPES]
125
+ records = sum(len(data[key]) for key in types)
126
+ line = f"{_DONE}. {_plural(records, 'record')} across {_plural(len(types), 'type')}"
127
+ auth = [key for key in ("DMARC", "DKIM") if key in data]
128
+ return f"{line} · {', '.join(auth)}." if auth else f"{line}."
129
+
130
+
131
+ def _whois(result: ModuleResult) -> str:
132
+ data = result.data or {}
133
+ if not data:
134
+ return f"{_DONE}. No WHOIS data returned."
135
+ if (note := _note(data)) is not None:
136
+ return f"{_DONE}. {_sentence(note)}"
137
+ bits = []
138
+ if registrar := data.get("registrar"):
139
+ # Unlabelled: the Whois prefix and the "expires" half make it obvious enough,
140
+ # and the 10 chars "Registrar " costs are better spent on the name itself.
141
+ bits.append(_esc(registrar))
142
+ if expiry := _date_only(data.get("expiry_date")):
143
+ bits.append(f"expires {_esc(expiry)}")
144
+ # Sparse ccTLD output can carry neither field; say what did come back.
145
+ return f"{_DONE}. {' · '.join(bits) or _plural(len(data), 'field')}."
146
+
147
+
148
+ def _subdomains(result: ModuleResult) -> str:
149
+ found = result.data or []
150
+ if not found:
151
+ return f"{_DONE}. None found."
152
+ return f"{_DONE}. Found {_plural(len(found), 'subdomain')}."
153
+
154
+
155
+ def _ssl(result: ModuleResult) -> str:
156
+ data = result.data or {}
157
+ if not data:
158
+ return "No certificate found (TLS handshake failed)."
159
+ issuer = _esc(data.get("issuer_org") or data.get("issuer_cn") or "unknown issuer")
160
+ # san_count, not len(san) — the san list is capped at 20.
161
+ sans = _plural(data.get("san_count") or 0, "SAN")
162
+ if data.get("expired"):
163
+ return f"Certificate [{RED}]expired[/] — {issuer} · {sans}."
164
+ bits = [issuer]
165
+ # days_until_expiry is absent when the notAfter date failed to parse.
166
+ if (days := data.get("days_until_expiry")) is not None:
167
+ bits.append(f"{_plural(days, 'day')} left")
168
+ bits.append(sans)
169
+ return f"{_DONE}. {' · '.join(bits)}."
170
+
171
+
172
+ def _security(result: ModuleResult) -> str:
173
+ # All three tables are fixed-length (every port/header/blocklist gets a row), so
174
+ # the counts have to come from the values, not len().
175
+ sections = _sections(result)
176
+ ports = sections.get("Open Ports", {})
177
+ headers = sections.get("HTTP Security", {})
178
+ blocklists = sections.get("Blocklists", {})
179
+
180
+ if (note := _note(ports)) is not None:
181
+ port_bit = _sentence(note).rstrip(".") # the module's wording, leading the list
182
+ else:
183
+ port_bit = _plural(sum(1 for v in ports.values() if _plain(v) == "open"), "open port")
184
+ present = sum(1 for v in headers.values() if _plain(v) == "Yes")
185
+ # Exact match: "Not Blocked" contains "Blocked".
186
+ blocked = sum(1 for v in blocklists.values() if _plain(v) == "Blocked")
187
+ return f"{_DONE}. {port_bit}, {present}/{len(headers)} headers, {blocked} blocked."
188
+
189
+
190
+ def _headers(result: ModuleResult) -> str:
191
+ data = result.data or {}
192
+ if not data:
193
+ return f"{_DONE}. No headers (fetch failed)."
194
+ # dict(ctx.headers) drops requests' CaseInsensitiveDict, so keys keep whatever
195
+ # casing the server sent.
196
+ server = next((v for k, v in data.items() if k.lower() == "server"), None)
197
+ line = f"{_DONE}. {_plural(len(data), 'header')}"
198
+ # Whole value, incl. "Apache/2.4.41 (Ubuntu) mod_wsgi/4.6.8" — it's the last
199
+ # thing on the line, so a narrow panel ellipsises only its tail.
200
+ return f"{line} · Server {_esc(server)}." if server else f"{line}."
201
+
202
+
203
+ def _tech(result: ModuleResult) -> str:
204
+ # A tech declaring three groups appears in three Grids — dedupe on name.
205
+ sections = result.data or []
206
+ names = {name for section in sections for name in section.data.names}
207
+ if not names:
208
+ return f"{_DONE}. No technologies detected."
209
+ return (
210
+ f"{_DONE}. {_plural(len(names), 'technology', 'technologies')} "
211
+ f"across {_plural(len(sections), 'group')}."
212
+ )
213
+
214
+
215
+ def _seo(result: ModuleResult) -> str:
216
+ sections = _sections(result)
217
+ content = sections.get("Content", {})
218
+ if (note := _note(content)) is not None:
219
+ return f"{_DONE}. {_sentence(note)}"
220
+ bits = []
221
+ if content.get("Title", "-") != "-":
222
+ bits.append("title")
223
+ if content.get("Description", "-") != "-":
224
+ bits.append("desc")
225
+ bits.append(_plural(len(content.get("H1") or []), "H1", "H1s"))
226
+ if _plain(sections.get("Schema", {}).get("Has Schema", "")) == "Yes":
227
+ bits.append("schema")
228
+ if _plain(sections.get("Robots", {}).get("Found", "")) == "Yes":
229
+ bits.append("robots")
230
+ return f"{_DONE}. {_sentence(', '.join(bits))}"
231
+
232
+
233
+ def _sitemap(result: ModuleResult) -> str:
234
+ root = result.data
235
+ if root is None:
236
+ return f"{_DONE}. No sitemap found."
237
+ total = root.total or 0
238
+ suffix = " (truncated)" if total >= MAX_URLS else ""
239
+ return f"{_DONE}. {_plural(total, 'URL')} found{suffix}."
240
+
241
+
242
+ def _links(result: ModuleResult) -> str:
243
+ # An empty section holds one placeholder row, so len() reads 1 for "none".
244
+ sections = _sections(result)
245
+ internal = sections.get("Internal", [])
246
+ external = sections.get("External", [])
247
+ n_internal = 0 if internal == [EMPTY_INTERNAL] else len(internal)
248
+ n_external = 0 if external == [EMPTY_EXTERNAL] else len(external)
249
+ if not n_internal and not n_external:
250
+ return f"{_DONE}. No links found."
251
+ return f"{_DONE}. {n_internal} internal, {n_external} external links."
252
+
253
+
254
+ _SUMMARIZERS: dict[str, Callable[[ModuleResult], str]] = {
255
+ "dns": _dns,
256
+ "whois": _whois,
257
+ "subdomains": _subdomains,
258
+ "ssl": _ssl,
259
+ "security": _security,
260
+ "headers": _headers,
261
+ "tech": _tech,
262
+ "seo": _seo,
263
+ "sitemap": _sitemap,
264
+ "links": _links,
265
+ }
266
+
267
+
268
+ # ---- public API -----------------------------------------------------------
269
+
270
+
271
+ def started(target: str, count: int) -> str:
272
+ """The opening line."""
273
+ return f"Scan: Started {_esc(target)} · {_plural(count, 'module')}."
274
+
275
+
276
+ def overall(completed: int, failed: int, total: int, seconds: float) -> str:
277
+ """The closing line. ``seconds`` is wall-clock for the whole scan — modules run
278
+ concurrently, so summing their durations would overstate it several-fold."""
279
+ ok = completed - failed
280
+ took = f"in {round(seconds)}s"
281
+ if failed:
282
+ return (
283
+ f"Overall Scan Status: {ok}/{total} modules [{GREEN}]completed[/] {took}, "
284
+ f"[{RED}]{_plural(failed, 'error')}[/]."
285
+ )
286
+ return f"Overall Scan Status: {total}/{total} modules [{GREEN}]completed[/] {took}."
287
+
288
+
289
+ def _prefetch(ctx: ScanContext | None) -> str:
290
+ if ctx is None:
291
+ return f"Prefetch: {_ERROR}. No scan context."
292
+ if ctx.fetch_error:
293
+ return f"Prefetch: [{RED}]error[/] — {_esc(ctx.fetch_error)}."
294
+ bits = [_esc(ctx.ip)] if ctx.ip else []
295
+ if ctx.status_code is not None:
296
+ bits.append(f"HTTP {ctx.status_code}")
297
+ if ctx.response_time_ms is not None:
298
+ bits.append(f"{round(ctx.response_time_ms)}ms")
299
+ return f"Prefetch: {' · '.join(bits) or 'no response'}."
300
+
301
+
302
+ def summarize(event: ScanEvent, ctx: ScanContext | None) -> str | None:
303
+ """One log line for a ScanEvent, or ``None`` if it shouldn't be logged.
304
+
305
+ Module RUNNING events are dropped on purpose: all ten fire in a single
306
+ ``asyncio.gather`` burst, so logging them would push ten lines through a
307
+ four-line panel in one tick and none of them would be read.
308
+ """
309
+ if event.name == PREFETCH:
310
+ # Prefetch events never carry a result — the numbers live on the context.
311
+ return _prefetch(ctx) if event.status is ModuleStatus.DONE else None
312
+ if event.status is ModuleStatus.RUNNING:
313
+ return None
314
+
315
+ label = _LABELS.get(event.name, event.name)
316
+ result = event.result
317
+ if result is None or event.status is ModuleStatus.FAILED:
318
+ error = result.error if result is not None else None
319
+ return f"{label}: {_ERROR}. {_short_error(error)}"
320
+ try:
321
+ body = _SUMMARIZERS[event.name](result)
322
+ except Exception: # noqa: BLE001
323
+ # Modules are failure-isolated; the handler that logs them isn't. A summary
324
+ # that trips over an unexpected shape must not take the scan down with it.
325
+ body = f"{_DONE}."
326
+ return f"{label}: {body}"
@@ -12,6 +12,10 @@ live as each module completes.
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import re
16
+ import time
17
+
18
+ from bs4 import BeautifulSoup
15
19
  from textual.app import App, ComposeResult
16
20
  from textual.binding import Binding
17
21
  from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
@@ -21,9 +25,10 @@ from textual.widgets import Input, LoadingIndicator, Static
21
25
  from ..core import AsyncScanner, ModuleStatus, ScanContext, ScanEvent
22
26
  from ..core.scanner import PREFETCH
23
27
  from ..modules import all_modules
28
+ from . import activity
24
29
  from .export import export_csvs
25
30
  from .tables import render_result
26
- from .widgets import MapPanel, SitemapTree, StatusPanel, TabBar, Tab
31
+ from .widgets import ActivityLog, MapPanel, SitemapTree, StatusPanel, TabBar, Tab
27
32
 
28
33
  #: width (cells) of the footer progress bar and its dim (incomplete) colour
29
34
  _BAR_WIDTH = 22
@@ -46,6 +51,73 @@ def _cms_from_tech(data: object) -> tuple[str, str | None] | None:
46
51
  return None
47
52
 
48
53
 
54
+ #: matches a ``<meta name="generator">`` name attribute — real pages ship
55
+ #: ``name="Generator"`` as often as lowercase, so the value is matched case-insensitively
56
+ _GENERATOR_ATTR = re.compile(r"^\s*generator\s*$", re.I)
57
+
58
+
59
+ def _split_generator(content: str) -> tuple[str, str | None]:
60
+ """Split a ``<meta name="generator">`` content value into (name, version|None).
61
+
62
+ The version is the first token that contains a digit, plus everything after it
63
+ ("Sitefinity 14.4.8152.0 DX" → ``("Sitefinity", "14.4.8152.0 DX")``); a value with
64
+ no such token is all name ("Webflow" → ``("Webflow", None)``). A leading digit-ish
65
+ token is part of the name, not a version ("1C-Bitrix", "TYPO3 CMS"). Trailing
66
+ parentheticals are dropped ("Drupal 10 (https://www.drupal.org)" → "Drupal", "10").
67
+ """
68
+ content = re.sub(r"\s*\([^)]*\)", "", content).strip()
69
+ tokens = content.split()
70
+ for i, token in enumerate(tokens):
71
+ if i and any(char.isdigit() for char in token):
72
+ return " ".join(tokens[:i]), " ".join(tokens[i:])
73
+ return content, None
74
+
75
+
76
+ def _generators(html: str | None) -> list[tuple[str, str | None]]:
77
+ """Every ``<meta name="generator">`` in the page, as (name, version|None) pairs."""
78
+ if not html:
79
+ return []
80
+ soup = BeautifulSoup(html, "html.parser")
81
+ found = []
82
+ for tag in soup.find_all("meta", attrs={"name": _GENERATOR_ATTR}):
83
+ content = (tag.get("content") or "").strip()
84
+ if content:
85
+ found.append(_split_generator(content))
86
+ return found
87
+
88
+
89
+ def _same_cms(left: str, right: str) -> bool:
90
+ """Do two CMS names refer to the same product? Compared on letters/digits only,
91
+ either containing the other, so "Wix" matches "Wix.com Website Builder"."""
92
+ left, right = (re.sub(r"[^a-z0-9]", "", name.lower()) for name in (left, right))
93
+ return bool(left and right) and (left in right or right in left)
94
+
95
+
96
+ def _detect_cms(tech_data: object, html: str | None) -> tuple[str, str | None] | None:
97
+ """The CMS (name, version) for the Server panel, or ``None`` if nothing detected.
98
+
99
+ Wappalyzer's ``CMS`` category is the primary signal — it's curated, so it won't
100
+ mistake a page builder or analytics tag for the CMS. The ``<meta name="generator">``
101
+ tag covers the two cases it misses: a CMS Wappalyzer has no fingerprint for (e.g.
102
+ Sitefinity, Webflow) → the generator is used outright; and a CMS it detects but
103
+ can't version → the generator supplies the version, but only when it names the
104
+ *same* product, so an "Elementor 3.x" generator can't hijack a "WordPress" hit.
105
+ """
106
+ tech = _cms_from_tech(tech_data)
107
+ generators = _generators(html)
108
+ if tech is None:
109
+ # Nothing in the tech stack — fall back to the generator, preferring a
110
+ # versioned one when the page carries several.
111
+ versioned = next((gen for gen in generators if gen[1]), None)
112
+ return versioned or (generators[0] if generators else None)
113
+ name, version = tech
114
+ if version is None:
115
+ for gen_name, gen_version in generators:
116
+ if gen_version and _same_cms(name, gen_name):
117
+ return name, gen_version
118
+ return tech
119
+
120
+
49
121
  class ScanProgress(Message):
50
122
  """A ScanEvent surfaced onto the Textual message pump."""
51
123
 
@@ -98,6 +170,9 @@ class WebScannerApp(App):
98
170
  self.completed = 0
99
171
  self.failed = 0
100
172
  self._scanning = False
173
+ # monotonic (not wall clock — immune to the system clock moving) start of the
174
+ # current scan, for the log's closing "completed in Ns".
175
+ self._scan_start = 0.0
101
176
  # last result rendered into the sitemap Tree, so switching tabs doesn't
102
177
  # rebuild (and re-collapse) it every visit.
103
178
  self._tree_result = None
@@ -109,10 +184,12 @@ class WebScannerApp(App):
109
184
  yield Input(value=self._target or "", placeholder="domain… (press enter to scan)", id="domain")
110
185
  yield TabBar(self.modules, id="tabs")
111
186
  with Grid(id="grid"):
112
- with VerticalScroll(id="main"):
113
- yield LoadingIndicator(id="main-loading")
114
- yield Static("", id="main-content")
115
- yield SitemapTree(id="main-tree")
187
+ with Vertical(id="left"):
188
+ with VerticalScroll(id="main"):
189
+ yield LoadingIndicator(id="main-loading")
190
+ yield Static("", id="main-content")
191
+ yield SitemapTree(id="main-tree")
192
+ yield ActivityLog(id="activity")
116
193
  yield MapPanel(id="map")
117
194
  with VerticalScroll(id="status"):
118
195
  yield StatusPanel(id="status-content")
@@ -124,6 +201,7 @@ class WebScannerApp(App):
124
201
  self.query_one("#topbar").border_title = "🌐 WebScanner"
125
202
  self.query_one("#map").border_title = "server location"
126
203
  self.query_one("#status", VerticalScroll).border_title = "Server"
204
+ self.query_one("#activity", ActivityLog).border_title = "Activity Log"
127
205
  self.query_one("#tabs", TabBar).set_selected(self.selected)
128
206
  self._update_main_title()
129
207
  self._set_keybar(editing=False)
@@ -141,6 +219,7 @@ class WebScannerApp(App):
141
219
  self.completed = 0
142
220
  self.failed = 0
143
221
  self._scanning = True
222
+ self._scan_start = time.monotonic()
144
223
  self._tree_result = None
145
224
 
146
225
  tabs = self.query_one("#tabs", TabBar)
@@ -150,6 +229,9 @@ class WebScannerApp(App):
150
229
  self._set_main_loading(True)
151
230
  self.query_one("#map", MapPanel).show_loading()
152
231
  self.query_one("#status-content", StatusPanel).show_loading(self.ctx)
232
+ log = self.query_one("#activity", ActivityLog)
233
+ log.clear()
234
+ log.add(activity.started(self.ctx.domain, len(self.modules)))
153
235
  self._update_progress()
154
236
 
155
237
  # Blur the input so single-key nav (←/→, q, r) reaches the app rather
@@ -174,6 +256,9 @@ class WebScannerApp(App):
174
256
 
175
257
  def on_scan_progress(self, message: ScanProgress) -> None:
176
258
  event = message.event
259
+ # Above the early returns below — the log wants prefetch events too.
260
+ if (line := activity.summarize(event, self.ctx)) is not None:
261
+ self.query_one("#activity", ActivityLog).add(line)
177
262
  if event.name == PREFETCH:
178
263
  if event.status is ModuleStatus.DONE and self.ctx is not None:
179
264
  self.query_one("#map", MapPanel).set_geo(self.ctx.geo)
@@ -193,16 +278,24 @@ class WebScannerApp(App):
193
278
  self.results[event.name] = event.result
194
279
  if event.name == self.selected:
195
280
  self._refresh_main()
196
- if event.name == "tech" and self.ctx is not None and event.result is not None:
281
+ if event.name == "tech" and self.ctx is not None:
282
+ # No result guard: a failed Tech scan can still surface a CMS via the
283
+ # page's <meta name="generator">.
284
+ data = event.result.data if event.result is not None else None
197
285
  self.query_one("#status-content", StatusPanel).set_ctx(
198
- self.ctx, cms=_cms_from_tech(event.result.data)
286
+ self.ctx, cms=_detect_cms(data, self.ctx.html)
199
287
  )
200
288
  self._update_progress()
201
289
 
202
290
  def on_scan_finished(self, message: ScanFinished) -> None:
203
291
  self._scanning = False
204
292
  total = len(self.modules)
205
- self.query_one("#progress", Static).update(f"done · {total}/{total}")
293
+ self.query_one("#activity", ActivityLog).add(
294
+ activity.overall(
295
+ self.completed, self.failed, total, time.monotonic() - self._scan_start
296
+ )
297
+ )
298
+ self.query_one("#progress", Static).update("")
206
299
 
207
300
  # ---- progress line ----------------------------------------------------
208
301
 
@@ -73,9 +73,18 @@ Tab.-selected {
73
73
  height: 1fr; /* fill remaining screen so 1fr rows split equally */
74
74
  }
75
75
 
76
- #main {
76
+ /* Left column: the data table over the fixed activity log. A bare wrapper (no
77
+ border/padding of its own) so the grid still sees one cell here. */
78
+ #left {
77
79
  row-span: 2;
78
80
  height: 100%;
81
+ }
82
+
83
+ #main {
84
+ height: 4fr; /* 80% of the left column (activity takes the other 20%). Must
85
+ stay fr, NOT 100%: that claims the whole column and silently
86
+ clips #activity (Vertical is overflow: hidden, so no
87
+ scrollbar, no error — the panel just never appears). */
79
88
  border: round $primary;
80
89
  padding: 0 1;
81
90
  scrollbar-size: 0 0; /* keep wheel/key scroll, hide the bar */
@@ -112,6 +121,13 @@ Tab.-selected {
112
121
  color: $primary;
113
122
  text-style: bold;
114
123
  }
124
+ /* The expand/collapse chevron. Not a stock Textual component class — SitemapTree
125
+ adds it and paints the icon span itself, because Textual renders the chevron in
126
+ the widget's base style, which no CSS can reach without dragging the labels
127
+ along. White lifts it clear of the labels (ANSI default fg, ~#c5c8c6). */
128
+ #main-tree > .tree--toggle {
129
+ color: white;
130
+ }
115
131
  /* Leaf pages are @click links (click to open the URL). Neutralise Textual's default
116
132
  link styling so they render EXACTLY like plain leaf text — no underline, no hover
117
133
  colour/background change. */
@@ -124,6 +140,22 @@ Tab.-selected {
124
140
  link-background-hover: transparent;
125
141
  }
126
142
 
143
+ /* Activity log: 20% of the left column, so it grows with the terminal (#main is
144
+ the other 80%). 4 rows of that go to the border + padding (box-sizing is
145
+ border-box), so visible lines = height - 4: ~3 at 44 rows, ~6 at 60.
146
+ min-height floors it at one readable line on a short terminal, where 20% would
147
+ otherwise round down to nothing but chrome.
148
+ background: transparent overrides RichLog's $surface, which would punch an
149
+ opaque block through the ansi_default bg. */
150
+ #activity {
151
+ height: 1fr;
152
+ min-height: 5;
153
+ border: round $primary;
154
+ padding: 1 1;
155
+ background: transparent;
156
+ scrollbar-size: 0 0;
157
+ }
158
+
127
159
  #map {
128
160
  height: 100%;
129
161
  border: round $primary;
@@ -13,10 +13,8 @@ from datetime import datetime
13
13
  from pathlib import Path
14
14
  from typing import Any, Iterator
15
15
 
16
- from rich.text import Text
17
-
18
16
  from ..core.models import Grid, ModuleStatus, Sections, TreeNode
19
- from .tables import TAB_HEADERS, _SMART_LABEL_TABS, _label
17
+ from .tables import TAB_HEADERS, _SMART_LABEL_TABS, _label, _plain
20
18
 
21
19
  def _output_base() -> Path:
22
20
  """Parent directory for the ``<domain>_<ts>/`` scan folder.
@@ -34,22 +32,6 @@ def _output_base() -> Path:
34
32
  return cwd
35
33
 
36
34
 
37
- def _stringify(value: Any) -> str:
38
- if isinstance(value, (list, tuple, set)):
39
- return "\n".join(str(v) for v in value) if value else "-"
40
- if isinstance(value, bool):
41
- return "yes" if value else "no"
42
- if value is None:
43
- return "-"
44
- return str(value)
45
-
46
-
47
- def _plain(value: Any) -> str:
48
- """Stringify and strip rich markup (e.g. ``[green]Yes[/]`` -> ``Yes``)."""
49
- s = _stringify(value)
50
- return Text.from_markup(s).plain if "[/]" in s else s
51
-
52
-
53
35
  def _is_pairs(data: Any) -> bool:
54
36
  return (
55
37
  isinstance(data, (list, tuple))
@@ -139,6 +139,16 @@ def _stringify(value: Any) -> str:
139
139
  return str(value)
140
140
 
141
141
 
142
+ def _plain(value: Any) -> str:
143
+ """Stringify and strip rich markup (e.g. ``[green]Yes[/]`` -> ``Yes``).
144
+
145
+ Shared with export (CSV cells) and activity (log counts) — both need the plain
146
+ text behind a module's colour markup.
147
+ """
148
+ s = _stringify(value)
149
+ return Text.from_markup(s).plain if "[/]" in s else s
150
+
151
+
142
152
  def _label(key: Any, mode: str = "upper") -> str:
143
153
  """Format a dict key as a column label. ``mode``: ``"upper"`` (ALL CAPS),
144
154
  ``"smart"`` (Title Case, acronyms kept upper), or ``"raw"`` (separators
@@ -1,13 +1,18 @@
1
- """Custom Textual widgets: tab bar, world-map panel, status panel."""
1
+ """Custom Textual widgets: tab bar, world-map panel, status panel, activity log."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections import deque
6
+ from datetime import datetime
7
+
5
8
  from rich.markup import escape
9
+ from rich.text import Text
6
10
  from textual.binding import Binding
7
11
  from textual.containers import Horizontal
8
12
  from textual.message import Message
9
- from textual.widgets import Static, Tree
13
+ from textual.widgets import RichLog, Static, Tree
10
14
 
15
+ from ..colors import BODY, MUTED
11
16
  from ..core.context import ScanContext
12
17
  from ..core.models import ModuleStatus, TreeNode
13
18
  from .tables import UNSET, render_status
@@ -103,6 +108,85 @@ class StatusPanel(Static):
103
108
  self.update(render_status(ctx, cms))
104
109
 
105
110
 
111
+ class ActivityLog(RichLog):
112
+ """Fixed scan log under the main panel; the newest line is always in view.
113
+
114
+ Lines are composed by ``activity.py`` — this only stamps the time, paints the
115
+ body, and decides how a too-long line is cut, so the wording stays testable
116
+ without an app.
117
+
118
+ Each line is written as a ``no_wrap`` / ``overflow="ellipsis"`` Text at the
119
+ panel's exact width, so it's truncated with "…" at the panel edge and a wider
120
+ terminal reveals more of it. Nothing is pre-cropped to an assumed width.
121
+
122
+ ``can_focus=False`` is load-bearing, not tidiness: RichLog inherits
123
+ ScrollableContainer's ungated up/down bindings, so a focused log would swallow
124
+ ↑/↓ from the Sitemap tree once it overflows. It also stops a click on the panel
125
+ from taking focus off the app, which is what keeps single-key nav (q/r/s/←/→)
126
+ working.
127
+ """
128
+
129
+ can_focus = False
130
+
131
+ #: scrollback kept for re-rendering on resize; matches RichLog's max_lines
132
+ _MAX_LINES = 200
133
+
134
+ def __init__(self, **kwargs) -> None:
135
+ # min_width=0: the 78 default renders every write at >=78 cells, overflowing
136
+ # a narrower panel. wrap=True: with wrap=False, RichLog forces
137
+ # overflow="ignore" on Text and the tail vanishes with no ellipsis.
138
+ super().__init__(markup=True, wrap=True, min_width=0, max_lines=self._MAX_LINES, **kwargs)
139
+ # RichLog renders to strips at write time and never re-renders, so keep the
140
+ # markup to replay when the width changes.
141
+ self._entries: deque[str] = deque(maxlen=self._MAX_LINES)
142
+ self._rendered_width: int | None = None
143
+
144
+ def add(self, body: str) -> None:
145
+ """Write one line, stamped with a muted [HH:MM:SS]."""
146
+ stamp = escape(f"[{datetime.now():%H:%M:%S}]")
147
+ markup = f"[{MUTED}]{stamp}[/] [{BODY}]{body}[/]"
148
+ self._entries.append(markup)
149
+ self._write(markup)
150
+
151
+ def _write(self, markup: str) -> None:
152
+ text = Text.from_markup(markup)
153
+ text.no_wrap = True
154
+ text.overflow = "ellipsis"
155
+ # Explicit width, because RichLog's own sizing measures against
156
+ # `app.console`, which is a plain 80 columns regardless of how wide the app
157
+ # actually is — every line would ellipsise at 80 on a wider terminal. Passing
158
+ # width bypasses measure/shrink/min_width entirely. Width 0 means we aren't
159
+ # laid out yet: let RichLog defer the write, and on_resize replays it.
160
+ width = self.scrollable_content_region.width
161
+ if width:
162
+ self.write(text, width=width)
163
+ else:
164
+ self.write(text)
165
+
166
+ def clear(self) -> "ActivityLog":
167
+ self._entries.clear()
168
+ super().clear()
169
+ return self
170
+
171
+ def on_resize(self, event) -> None:
172
+ # Flushes any deferred writes, once, when a size is first known.
173
+ super().on_resize(event)
174
+ width = self.scrollable_content_region.width
175
+ if not width or width == self._rendered_width:
176
+ return
177
+ self._rendered_width = width
178
+ if not self._entries:
179
+ return
180
+ # RichLog renders to strips at write time and never re-renders, so replay at
181
+ # the new width: a wider terminal reveals more of each line, a narrower one
182
+ # re-ellipsises. Also fixes the first sizing, where deferred lines were
183
+ # rendered by RichLog without our explicit width.
184
+ entries = list(self._entries)
185
+ super().clear()
186
+ for markup in entries:
187
+ self._write(markup)
188
+
189
+
106
190
  class SitemapTree(Tree):
107
191
  """Sitemap tab: the site's URL-path hierarchy as a collapsed tree.
108
192
 
@@ -124,6 +208,12 @@ class SitemapTree(Tree):
124
208
  Binding("space", "toggle_all", "Expand/collapse all", show=False),
125
209
  ]
126
210
 
211
+ #: Textual gives the expand/collapse chevron no component class of its own — it
212
+ #: renders with the widget's base style, so plain CSS can't reach it without
213
+ #: recolouring every label too. Adding one here (Textual unions COMPONENT_CLASSES
214
+ #: across the MRO) lets `#main-tree > .tree--toggle` in app.tcss style it alone.
215
+ COMPONENT_CLASSES = {"tree--toggle"}
216
+
127
217
  def __init__(self, **kwargs) -> None:
128
218
  super().__init__("/", **kwargs)
129
219
  self.show_root = True
@@ -152,6 +242,30 @@ class SitemapTree(Tree):
152
242
  else:
153
243
  parent.add_leaf(data.label) # e.g. the "… (truncated)" note — not a link
154
244
 
245
+ def render_label(self, node, base_style, style) -> Text:
246
+ """Paint the chevron with ``tree--toggle``, leaving the label untouched.
247
+
248
+ Textual builds the line as ``ICON + label``, styling the icon with the
249
+ widget's own base style; re-styling that span is the only way to colour the
250
+ chevron independently. The icon is a fixed-width prefix (``"▼ "``/``"▶ "``)
251
+ present only on expandable nodes, so the span is the first ``len(ICON_NODE)``
252
+ cells. A partial style layers over the base, so only the colour changes.
253
+
254
+ The ``_component_styles`` guard is required, not defensive: this also runs
255
+ pre-mount via ``get_label_width`` (a reactive in ``__init__`` rebuilds the
256
+ tree lines), and component styles don't exist until the stylesheet is applied
257
+ on mount — asking for one before that raises ``KeyError``. That early call
258
+ only measures, and a colour never changes cell width, so skipping is safe.
259
+ """
260
+ label = super().render_label(node, base_style, style)
261
+ if node.allow_expand and "tree--toggle" in self._component_styles:
262
+ label.stylize(
263
+ self.get_component_rich_style("tree--toggle", partial=True),
264
+ 0,
265
+ len(self.ICON_NODE),
266
+ )
267
+ return label
268
+
155
269
  def action_open_leaf(self, index: int) -> None:
156
270
  if 0 <= index < len(self._leaf_urls):
157
271
  self.app.open_url(self._leaf_urls[index])
File without changes
File without changes
File without changes
File without changes
File without changes