web-scanner 2.0.0__py3-none-any.whl

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.
webscanner/ui/app.py ADDED
@@ -0,0 +1,287 @@
1
+ """WebScanner v2 Textual application.
2
+
3
+ Layout mirrors the dnsglobe reference: a bordered top bar (domain input + tab
4
+ row), an inline animated progress line, then a 2-column grid — main data table
5
+ (left, full height), fixed world map (top-right) and fixed status panel
6
+ (bottom-right) — over a keybind footer.
7
+
8
+ The scan runs as an async worker; the orchestrator's ScanEvents arrive as
9
+ ``ScanProgress`` messages that colour tabs, advance progress and fill panels
10
+ live as each module completes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from textual.app import App, ComposeResult
16
+ from textual.binding import Binding
17
+ from textual.containers import Grid, Horizontal, Vertical, VerticalScroll
18
+ from textual.message import Message
19
+ from textual.widgets import Input, Static
20
+
21
+ from ..core import AsyncScanner, ModuleStatus, ScanContext, ScanEvent
22
+ from ..core.scanner import PREFETCH
23
+ from ..modules import all_modules
24
+ from .export import export_csvs
25
+ from .tables import render_result
26
+ from .widgets import MapPanel, StatusPanel, TabBar, Tab
27
+
28
+ _SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
29
+
30
+
31
+ class ScanProgress(Message):
32
+ """A ScanEvent surfaced onto the Textual message pump."""
33
+
34
+ def __init__(self, event: ScanEvent) -> None:
35
+ self.event = event
36
+ super().__init__()
37
+
38
+
39
+ class ScanFinished(Message):
40
+ pass
41
+
42
+
43
+ class WebScannerApp(App):
44
+ CSS_PATH = "app.tcss"
45
+ TITLE = "WebScanner"
46
+ # We manage focus by hand (input focused only when editing the domain) so
47
+ # single-key nav reaches the app; disable Textual's auto-refocus.
48
+ AUTO_FOCUS = None
49
+ # Custom keybar replaces the Footer, so drop the built-in command palette.
50
+ ENABLE_COMMAND_PALETTE = False
51
+
52
+ BINDINGS = [
53
+ Binding("ctrl+q", "quit", "Quit", show=False),
54
+ Binding("q", "quit", "Quit", show=False),
55
+ Binding("left", "prev_tab", "Prev tab", show=False),
56
+ Binding("right", "next_tab", "Next tab", show=False),
57
+ # Tab/Shift+Tab navigate tabs and are priority so Textual's focus
58
+ # traversal never runs (keeps the keybar stable while editing).
59
+ Binding("tab", "next_tab", show=False, priority=True),
60
+ Binding("shift+tab", "prev_tab", show=False, priority=True),
61
+ Binding("r", "rescan", "Rescan", show=False),
62
+ Binding("s", "save", "Save", show=False),
63
+ Binding("escape", "toggle_edit", "Edit domain", show=False),
64
+ Binding("plus,equals_sign", "zoom_in", "Zoom map in", show=False),
65
+ Binding("minus,underscore", "zoom_out", "Zoom map out", show=False),
66
+ ]
67
+
68
+ def __init__(self, target: str | None = None) -> None:
69
+ super().__init__()
70
+ self._target = target
71
+ self.ctx: ScanContext | None = None
72
+ self.modules = all_modules()
73
+ self.results: dict = {}
74
+ self.selected = self.modules[0].name
75
+ self.completed = 0
76
+ self.failed = 0
77
+ self._frame = 0
78
+ self._scanning = False
79
+
80
+ # ---- layout -----------------------------------------------------------
81
+
82
+ def compose(self) -> ComposeResult:
83
+ with Vertical(id="topbar"):
84
+ yield Input(value=self._target or "", placeholder="domain… (press enter to scan)", id="domain")
85
+ yield TabBar(self.modules, id="tabs")
86
+ with Grid(id="grid"):
87
+ with VerticalScroll(id="main"):
88
+ yield Static("", id="main-content")
89
+ yield MapPanel(id="map")
90
+ with VerticalScroll(id="status"):
91
+ yield StatusPanel(id="status-content")
92
+ with Horizontal(id="footer"):
93
+ yield Static("", id="keybar")
94
+ yield Static("", id="progress")
95
+
96
+ def on_mount(self) -> None:
97
+ self.query_one("#topbar").border_title = "🌐 WebScanner"
98
+ self.query_one("#map").border_title = "server location"
99
+ self.query_one("#status", VerticalScroll).border_title = "Server"
100
+ self._spinner_timer = self.set_interval(0.08, self._tick, pause=True)
101
+ self.query_one("#tabs", TabBar).set_selected(self.selected)
102
+ self._update_main_title()
103
+ self._set_keybar(editing=False)
104
+ if self._target:
105
+ self.start_scan(self._target)
106
+ else:
107
+ self.action_toggle_edit()
108
+
109
+ # ---- scanning ---------------------------------------------------------
110
+
111
+ def start_scan(self, target: str) -> None:
112
+ self.ctx = ScanContext.from_target(target)
113
+ self.modules = all_modules()
114
+ self.results = {}
115
+ self.completed = 0
116
+ self.failed = 0
117
+ self._scanning = True
118
+
119
+ tabs = self.query_one("#tabs", TabBar)
120
+ for module in self.modules:
121
+ tabs.set_status(module.name, ModuleStatus.PENDING)
122
+
123
+ self._set_main("[dim]scanning…[/]")
124
+ self.query_one("#map", MapPanel).show_loading()
125
+ self.query_one("#status-content", StatusPanel).show_loading(self.ctx)
126
+ self._spinner_timer.resume()
127
+
128
+ # Blur the input so single-key nav (←/→, q, r) reaches the app rather
129
+ # than being typed into the field; Escape refocuses it to edit.
130
+ self.set_focus(None)
131
+ self._set_keybar(editing=False)
132
+
133
+ scanner = AsyncScanner(self.ctx, self.modules, on_event=self._on_event)
134
+ self._run_scan(scanner)
135
+
136
+ def _on_event(self, event: ScanEvent) -> None:
137
+ # Runs inside the event loop (orchestrator coroutine); hand off via the
138
+ # message pump so all UI mutation happens in message handlers.
139
+ self.post_message(ScanProgress(event))
140
+
141
+ def _run_scan(self, scanner: AsyncScanner) -> None:
142
+ async def worker() -> None:
143
+ await scanner.run()
144
+ self.post_message(ScanFinished())
145
+
146
+ self.run_worker(worker(), exclusive=True, name="scan")
147
+
148
+ def on_scan_progress(self, message: ScanProgress) -> None:
149
+ event = message.event
150
+ if event.name == PREFETCH:
151
+ if event.status is ModuleStatus.DONE and self.ctx is not None:
152
+ self.query_one("#map", MapPanel).set_geo(self.ctx.geo)
153
+ self.query_one("#status-content", StatusPanel).set_ctx(self.ctx)
154
+ return
155
+
156
+ self.query_one("#tabs", TabBar).set_status(event.name, event.status)
157
+
158
+ if event.status is ModuleStatus.RUNNING:
159
+ return
160
+
161
+ # terminal status
162
+ self.completed += 1
163
+ if event.status is ModuleStatus.FAILED:
164
+ self.failed += 1
165
+ if event.result is not None:
166
+ self.results[event.name] = event.result
167
+ if event.name == self.selected:
168
+ self._refresh_main()
169
+ if event.name == "tech" and self.ctx is not None and event.result is not None:
170
+ self.query_one("#status-content", StatusPanel).set_ctx(self.ctx, event.result.data.names)
171
+ self._update_progress()
172
+
173
+ def on_scan_finished(self, message: ScanFinished) -> None:
174
+ self._scanning = False
175
+ self._spinner_timer.pause()
176
+ total = len(self.modules)
177
+ self.query_one("#progress", Static).update(f"done · {total}/{total}")
178
+
179
+ # ---- progress line ----------------------------------------------------
180
+
181
+ def _tick(self) -> None:
182
+ self._frame = (self._frame + 1) % len(_SPINNER)
183
+ self._update_progress()
184
+
185
+ def _update_progress(self) -> None:
186
+ if not self._scanning:
187
+ return
188
+ frame = _SPINNER[self._frame]
189
+ self.query_one("#progress", Static).update(
190
+ f"{frame} scanning… {self.completed}/{len(self.modules)}"
191
+ )
192
+
193
+ # ---- tab selection ----------------------------------------------------
194
+
195
+ def _module_names(self) -> list[str]:
196
+ return [m.name for m in self.modules]
197
+
198
+ def _select(self, name: str) -> None:
199
+ self.selected = name
200
+ self.query_one("#tabs", TabBar).set_selected(name)
201
+ self._refresh_main()
202
+
203
+ def _update_main_title(self) -> None:
204
+ label = next(m.label for m in self.modules if m.name == self.selected)
205
+ self.query_one("#main").border_title = label
206
+
207
+ def _set_main(self, markup: str) -> None:
208
+ # Leading blank line + space so placeholders sit clear of the border,
209
+ # matching where real section content begins.
210
+ self.query_one("#main-content", Static).update(f"\n {markup}")
211
+
212
+ def _refresh_main(self) -> None:
213
+ self._update_main_title()
214
+ result = self.results.get(self.selected)
215
+ if result is None:
216
+ self._set_main("[dim]scanning…[/]")
217
+ elif result.status is ModuleStatus.FAILED:
218
+ self._set_main(f"[red]failed:[/] {result.error}")
219
+ elif result.status is ModuleStatus.EMPTY:
220
+ self._set_main("[dim]no data found[/]")
221
+ else:
222
+ self.query_one("#main-content", Static).update(render_result(self.selected, result.data))
223
+
224
+ def on_tab_clicked(self, message: Tab.Clicked) -> None:
225
+ self._select(message.tab_name)
226
+
227
+ # ---- actions ----------------------------------------------------------
228
+
229
+ def action_prev_tab(self) -> None:
230
+ if self.focused and self.focused.id == "domain":
231
+ return
232
+ names = self._module_names()
233
+ idx = (names.index(self.selected) - 1) % len(names)
234
+ self._select(names[idx])
235
+
236
+ def action_next_tab(self) -> None:
237
+ if self.focused and self.focused.id == "domain":
238
+ return
239
+ names = self._module_names()
240
+ idx = (names.index(self.selected) + 1) % len(names)
241
+ self._select(names[idx])
242
+
243
+ def action_rescan(self) -> None:
244
+ if self.ctx is not None:
245
+ self.start_scan(self.ctx.domain)
246
+
247
+ def action_save(self) -> None:
248
+ """Save every completed tab to CSV under output/<domain>_<timestamp>/."""
249
+ if self.ctx is None or not self.results:
250
+ self.query_one("#progress", Static).update("[dim]nothing to save yet[/]")
251
+ return
252
+ folder = export_csvs(self.ctx.domain, self.modules, self.results)
253
+ msg = f"[dim]saved → {folder}[/]" if folder else "[dim]nothing to save yet[/]"
254
+ self.query_one("#progress", Static).update(msg)
255
+
256
+ def action_toggle_edit(self) -> None:
257
+ """Esc toggles domain editing (and the keybar) without moving other focus."""
258
+ editing = self.focused is not None and self.focused.id == "domain"
259
+ if editing:
260
+ self.set_focus(None)
261
+ self._set_keybar(editing=False)
262
+ else:
263
+ self.query_one("#domain", Input).focus()
264
+ self._set_keybar(editing=True)
265
+
266
+ def action_zoom_in(self) -> None:
267
+ self.query_one("#map", MapPanel).zoom_by(1)
268
+
269
+ def action_zoom_out(self) -> None:
270
+ self.query_one("#map", MapPanel).zoom_by(-1)
271
+
272
+ def on_input_submitted(self, message: Input.Submitted) -> None:
273
+ target = message.value.strip()
274
+ if target:
275
+ self.set_focus(None)
276
+ self.start_scan(target)
277
+
278
+ # ---- keybar -----------------------------------------------------------
279
+
280
+ def _set_keybar(self, editing: bool) -> None:
281
+ c = self.current_theme.primary # primary blue for key hints
282
+ if editing:
283
+ pairs = [("enter", "Scan"), ("esc", "Cancel")]
284
+ else:
285
+ pairs = [("q", "Quit"), ("←/→", "Tab"), ("r", "Rescan"), ("s", "Save"), ("esc", "Edit domain")]
286
+ text = " ".join(f"[b {c}]{k}[/] {label}" for k, label in pairs)
287
+ self.query_one("#keybar", Static).update(text)
webscanner/ui/app.tcss ADDED
@@ -0,0 +1,93 @@
1
+ Screen {
2
+ background: $background;
3
+ }
4
+
5
+ /* ---- top bar: domain input + tab row ---- */
6
+ #topbar {
7
+ height: auto;
8
+ border: round $primary;
9
+ padding: 1 1; /* gap above input + below tabs, off the border */
10
+ }
11
+
12
+ #domain {
13
+ border: none;
14
+ height: 1;
15
+ padding: 0;
16
+ background: transparent;
17
+ }
18
+
19
+ #tabs {
20
+ height: 1;
21
+ margin-top: 1;
22
+ }
23
+
24
+ /* Tabs stay dim regardless of scan status; only the selected tab is highlighted. */
25
+ Tab {
26
+ width: auto;
27
+ height: 1;
28
+ padding: 0 1;
29
+ margin-right: 1;
30
+ color: $text-muted;
31
+ }
32
+ Tab.-selected {
33
+ background: $primary;
34
+ color: $text;
35
+ text-style: bold;
36
+ }
37
+
38
+ /* ---- bottom footer row: keybar (left) + progress (right) ---- */
39
+ #footer {
40
+ height: 1;
41
+ background: $background;
42
+ }
43
+
44
+ /* ---- keybar (blends into the TUI bg) ---- */
45
+ #keybar {
46
+ width: 1fr;
47
+ height: 1;
48
+ padding: 0 1;
49
+ background: $background;
50
+ color: $text-muted;
51
+ }
52
+
53
+ /* ---- progress indicator, right-aligned on the footer row (always dim) ---- */
54
+ #progress {
55
+ width: auto;
56
+ height: 1;
57
+ padding: 0 1;
58
+ color: $text-muted;
59
+ text-align: right;
60
+ }
61
+
62
+ /* ---- main grid ---- */
63
+ #grid {
64
+ layout: grid;
65
+ grid-size: 2 2;
66
+ grid-columns: 2fr 1fr;
67
+ grid-rows: 1fr 1fr;
68
+ grid-gutter: 0;
69
+ height: 1fr; /* fill remaining screen so 1fr rows split equally */
70
+ }
71
+
72
+ #main {
73
+ row-span: 2;
74
+ height: 100%;
75
+ border: round $primary;
76
+ padding: 0 1;
77
+ scrollbar-size: 0 0; /* keep wheel/key scroll, hide the bar */
78
+ }
79
+
80
+ #map {
81
+ height: 100%;
82
+ border: round $primary;
83
+ padding: 0 1;
84
+ color: $success;
85
+ scrollbar-size: 0 0;
86
+ }
87
+
88
+ #status {
89
+ height: 100%;
90
+ border: round $primary;
91
+ padding: 1 1 0 2; /* space above the table and to the left */
92
+ scrollbar-size: 0 0; /* keep the bar hidden even if content overflows */
93
+ }