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.
@@ -0,0 +1,256 @@
1
+ """Rich renderables for the main data table and the fixed status panel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from rich.console import Group, RenderableType
8
+ from rich import box
9
+ from rich.table import Table
10
+ from rich.text import Text
11
+
12
+ from ..colors import RED
13
+ from ..core.context import ScanContext
14
+ from ..core.models import Grid, Sections
15
+
16
+ # Neutral, non-cyan key styling + a muted header + a very dim divider rule.
17
+ KEY_STYLE = "bold"
18
+ HEADER_STYLE = "bold grey62"
19
+ DIVIDER_STYLE = "grey23"
20
+ #: section-title chip — matches the selected-tab look (bg + 1-char side padding)
21
+ SECTION_STYLE = "bold white on #393939"
22
+
23
+ # Per-tab column headers (col1, col2). Meaningful names beat a generic pair since
24
+ # each tab's data is different. (Tabs with multi-table results, e.g. Security,
25
+ # carry headers per Section instead.)
26
+ TAB_HEADERS: dict[str, tuple[str, str]] = {
27
+ "dns": ("Record", "Value"),
28
+ "whois": ("Field", "Value"),
29
+ "subdomains": ("#", "Subdomain"),
30
+ "ssl": ("Field", "Value"),
31
+ "headers": ("Header", "Value"),
32
+ "content": ("Field", "Value"),
33
+ }
34
+
35
+
36
+ #: cap on the (content-fit) first-column width
37
+ MAX_KEY_WIDTH = 34
38
+
39
+
40
+ def render_result(name: str, data: Any) -> RenderableType:
41
+ """Render a module's result: multi-column Grid, stacked sub-tables (Sections),
42
+ or one key/value table."""
43
+ if isinstance(data, Grid):
44
+ return render_grid(data)
45
+ if isinstance(data, Sections):
46
+ return render_sections(data)
47
+ return render_table(data, TAB_HEADERS.get(name))
48
+
49
+
50
+ def render_grid(grid: Grid) -> Table:
51
+ """Render a multi-column table (e.g. Tech: Name/Category/Confidence/…).
52
+
53
+ First column is the primary name (bold); the rest are dim, matching the
54
+ key/value tables. Long list columns fold rather than truncate.
55
+ """
56
+ table = Table(
57
+ show_header=True,
58
+ header_style=HEADER_STYLE,
59
+ border_style=DIVIDER_STYLE,
60
+ box=box.SIMPLE,
61
+ expand=True,
62
+ pad_edge=False,
63
+ show_lines=False,
64
+ padding=(0, 1),
65
+ )
66
+ for i, col in enumerate(grid.columns):
67
+ if i == 0:
68
+ table.add_column(col, style=KEY_STYLE, no_wrap=True, overflow="ellipsis")
69
+ else:
70
+ table.add_column(col, style="dim", no_wrap=False, overflow="fold")
71
+ rows = [[str(cell) for cell in row] for row in grid]
72
+ for i, row in enumerate(rows):
73
+ table.add_row(*row)
74
+ if i != len(rows) - 1:
75
+ table.add_row(*[""] * len(grid.columns)) # blank spacer line
76
+ return table
77
+
78
+
79
+ def render_sections(sections: Sections) -> Group:
80
+ """Render several titled sub-tables stacked; content-fit sections share one
81
+ first-column width, ratio sections use their fixed proportions."""
82
+ widths = [_col1_width(s.data, s.headers, upper=False) for s in sections if not s.ratio]
83
+ key_width = max([w for w in widths if w], default=None)
84
+ parts: list[RenderableType] = [Text("")] # space above the first section title
85
+ for i, sec in enumerate(sections):
86
+ if i:
87
+ parts.append(Text(""))
88
+ # chip: 1-char padding inside the bg; the leading space also aligns the
89
+ # title text with the table's cell padding
90
+ parts.append(Text(f" {sec.title.upper()} ", style=SECTION_STYLE))
91
+ parts.append(
92
+ render_table(sec.data, sec.headers, upper=False, spaced=sec.spaced, key_width=key_width, ratio=sec.ratio)
93
+ )
94
+ return Group(*parts)
95
+
96
+
97
+ def _stringify(value: Any) -> str:
98
+ if isinstance(value, (list, tuple, set)):
99
+ return "\n".join(str(v) for v in value) if value else "-"
100
+ if isinstance(value, bool):
101
+ return "yes" if value else "no"
102
+ if value is None:
103
+ return "-"
104
+ return str(value)
105
+
106
+
107
+ def _label(key: Any, upper: bool) -> str:
108
+ s = str(key).replace("_", " ").replace("-", " ")
109
+ return s.upper() if upper else s
110
+
111
+
112
+ def _is_pairs(data: Any) -> bool:
113
+ """True for a list of (col1, col2) tuples (e.g. the Links tables)."""
114
+ return (
115
+ isinstance(data, (list, tuple))
116
+ and bool(data)
117
+ and all(isinstance(x, (list, tuple)) and len(x) == 2 for x in data)
118
+ )
119
+
120
+
121
+ def _value_cell(value: str) -> Text:
122
+ """Value cell: keep intentional colour markup (Security), otherwise dim."""
123
+ if "[/]" in value:
124
+ return Text.from_markup(value)
125
+ return Text(value, style="dim")
126
+
127
+
128
+ def _col1_width(data: Any, headers: tuple[str, str] | None, upper: bool) -> int | None:
129
+ """Content-fit width for the first column (incl. its header), or None for a
130
+ fixed index column."""
131
+ c1 = (headers or ("Field", "Value"))[0]
132
+ if isinstance(data, dict):
133
+ labels = [len(_label(k, upper)) for k in data]
134
+ elif _is_pairs(data):
135
+ labels = [len(str(a)) for a, _ in data]
136
+ else:
137
+ return None # list-of-scalars uses the fixed index width
138
+ return min(MAX_KEY_WIDTH, max(labels + [len(c1)], default=len(c1)))
139
+
140
+
141
+ def render_table(
142
+ data: Any,
143
+ headers: tuple[str, str] | None = None,
144
+ upper: bool = True,
145
+ spaced: bool = True,
146
+ key_width: int | None = None,
147
+ ratio: tuple[int, int] | None = None,
148
+ ) -> Table:
149
+ """Generic key/value (dict), pair-list, or indexed (list) table.
150
+
151
+ ``headers`` labels the two columns. ``upper`` upper-cases dict keys. ``spaced``
152
+ inserts a blank line between rows. ``key_width`` fixes the first-column width
153
+ (content-fit by default). ``ratio`` (col1, col2) forces proportional columns
154
+ instead of content-fit (e.g. (3, 2) for 60/40).
155
+ """
156
+ table = Table(
157
+ show_header=headers is not None,
158
+ header_style=HEADER_STYLE,
159
+ border_style=DIVIDER_STYLE, # very dim header rule
160
+ box=box.SIMPLE,
161
+ expand=True,
162
+ pad_edge=False,
163
+ show_lines=False,
164
+ padding=(0, 1), # tight; row spacing added via spacer rows below
165
+ )
166
+ if key_width is None:
167
+ key_width = _col1_width(data, headers, upper)
168
+
169
+ if isinstance(data, dict) or _is_pairs(data):
170
+ if isinstance(data, dict):
171
+ c1, c2 = headers or ("Field", "Value")
172
+ rows = [(_label(k, upper), _value_cell(_stringify(v))) for k, v in data.items()]
173
+ else:
174
+ c1, c2 = headers or ("Name", "Value")
175
+ rows = [(str(a), _value_cell(str(b))) for a, b in data]
176
+ if ratio:
177
+ table.add_column(c1, style=KEY_STYLE, ratio=ratio[0], no_wrap=False, overflow="fold")
178
+ table.add_column(c2, ratio=ratio[1], no_wrap=False, overflow="fold")
179
+ else:
180
+ table.add_column(c1, style=KEY_STYLE, width=key_width, no_wrap=True, overflow="ellipsis")
181
+ table.add_column(c2, no_wrap=False, overflow="fold")
182
+ _add_spaced(table, rows, spaced)
183
+ elif isinstance(data, (list, tuple)):
184
+ c1, c2 = headers or ("#", "Value")
185
+ table.add_column(c1, style="dim", width=5) # left-aligned index
186
+ table.add_column(c2, no_wrap=False, overflow="fold")
187
+ rows = [(str(i), _value_cell(str(row))) for i, row in enumerate(data, 1)]
188
+ _add_spaced(table, rows, spaced)
189
+ else:
190
+ table.add_column(headers[1] if headers else "Value")
191
+ table.add_row(_value_cell(str(data)))
192
+ return table
193
+
194
+
195
+ def _add_spaced(table: Table, rows: list[tuple[str, Any]], spaced: bool = True) -> None:
196
+ """Add rows, optionally with a blank spacer line between them."""
197
+ for i, (a, b) in enumerate(rows):
198
+ table.add_row(a, b)
199
+ if spaced and i != len(rows) - 1:
200
+ table.add_row("", "")
201
+
202
+
203
+ def _flag(country_code: str | None) -> str:
204
+ if not country_code or len(country_code) != 2:
205
+ return ""
206
+ return "".join(chr(0x1F1E6 + ord(c) - ord("A")) for c in country_code.upper())
207
+
208
+
209
+ def render_status(ctx: ScanContext, tech: list[str] | None = None) -> Table:
210
+ """Fixed status panel: online state, IP, location, ISP/AS, key tech."""
211
+ geo = ctx.geo or {}
212
+ table = Table(
213
+ show_header=False,
214
+ box=None,
215
+ expand=True,
216
+ pad_edge=False,
217
+ padding=(0, 1, 1, 0), # blank line under each row, matches main table
218
+ )
219
+ table.add_column("k", style=KEY_STYLE, width=10)
220
+ table.add_column("v", overflow="fold")
221
+
222
+ def dim(value: Any) -> Text:
223
+ return Text(str(value), style="dim")
224
+
225
+ if ctx.online:
226
+ state = Text.from_markup(
227
+ f"[green]● online[/] [dim]{ctx.status_code} · {ctx.response_time_ms:.0f}ms[/]"
228
+ )
229
+ elif ctx.fetch_error is not None:
230
+ state = Text.from_markup(f"[{RED}]● offline[/]")
231
+ else:
232
+ state = Text.from_markup("[dim]…[/]")
233
+ table.add_row("Status", state)
234
+ table.add_row("IP", dim(ctx.ip or "-"))
235
+
236
+ # Location: flag emoji stays in colour (not dimmed), the text is dim.
237
+ if geo:
238
+ loc = Text()
239
+ flag = _flag(geo.get("countryCode"))
240
+ if flag:
241
+ loc.append(flag + " ")
242
+ loc.append(f"{geo.get('city', '-')}, {geo.get('country', '-')}", style="dim")
243
+ else:
244
+ loc = dim("-")
245
+ table.add_row("Location", loc)
246
+
247
+ # ip-api's `org` is often the actual host (e.g. "Pantheon") vs the network
248
+ # ISP/AS (e.g. "Fastly"); show it when present and distinct.
249
+ org = geo.get("org")
250
+ if org and org != geo.get("isp"):
251
+ table.add_row("Host", dim(org))
252
+ table.add_row("ISP", dim(geo.get("isp") or "-"))
253
+ table.add_row("AS", dim(geo.get("as") or "-"))
254
+ if tech:
255
+ table.add_row("Tech", dim(", ".join(tech[:8])))
256
+ return table
@@ -0,0 +1,101 @@
1
+ """Custom Textual widgets: tab bar, world-map panel, status panel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual.containers import Horizontal
6
+ from textual.message import Message
7
+ from textual.widgets import Static
8
+
9
+ from ..core.context import ScanContext
10
+ from ..core.models import ModuleStatus
11
+ from .tables import render_status
12
+ from .worldmap import country_name, render as render_map
13
+
14
+ _STATUS_CLASSES = ("-pending", "-running", "-done", "-empty", "-failed")
15
+
16
+
17
+ class Tab(Static):
18
+ """A single tab in the tab bar; colour reflects its module's status."""
19
+
20
+ class Clicked(Message):
21
+ def __init__(self, tab_name: str) -> None:
22
+ self.tab_name = tab_name
23
+ super().__init__()
24
+
25
+ def __init__(self, name: str, label: str) -> None:
26
+ super().__init__(label, id=f"tab-{name}")
27
+ self.tab_name = name
28
+ self.add_class("-pending")
29
+
30
+ def set_status(self, status: ModuleStatus) -> None:
31
+ self.remove_class(*_STATUS_CLASSES)
32
+ self.add_class(f"-{status.value}")
33
+
34
+ def set_selected(self, selected: bool) -> None:
35
+ self.set_class(selected, "-selected")
36
+
37
+ def on_click(self) -> None:
38
+ self.post_message(self.Clicked(self.tab_name))
39
+
40
+
41
+ class TabBar(Horizontal):
42
+ def __init__(self, modules, **kwargs) -> None:
43
+ super().__init__(**kwargs)
44
+ self._modules = list(modules)
45
+
46
+ def compose(self):
47
+ for module in self._modules:
48
+ yield Tab(module.name, module.label)
49
+
50
+ def set_status(self, name: str, status: ModuleStatus) -> None:
51
+ self.query_one(f"#tab-{name}", Tab).set_status(status)
52
+
53
+ def set_selected(self, name: str) -> None:
54
+ for tab in self.query(Tab):
55
+ tab.set_selected(tab.tab_name == name)
56
+
57
+
58
+ class MapPanel(Static):
59
+ """Fixed country-level location map; re-renders on resize to fill the panel."""
60
+
61
+ _geo: dict | None = None
62
+ _zoom: float = 1.0
63
+
64
+ def set_geo(self, geo: dict | None) -> None:
65
+ self._geo = geo
66
+ self._draw()
67
+
68
+ def zoom_by(self, direction: int) -> None:
69
+ factor = 1.3 if direction > 0 else 1 / 1.3
70
+ self._zoom = max(0.25, min(6.0, self._zoom * factor))
71
+ self._draw()
72
+
73
+ def show_loading(self) -> None:
74
+ self.update("[dim]locating…[/]")
75
+
76
+ def on_resize(self) -> None:
77
+ if self._geo is not None:
78
+ self._draw()
79
+
80
+ def _draw(self) -> None:
81
+ geo = self._geo or {}
82
+ lat, lon = geo.get("lat"), geo.get("lon")
83
+ if lat is None:
84
+ self.update("[dim]no location[/]")
85
+ return
86
+ width = max(20, self.content_size.width)
87
+ height = max(6, self.content_size.height)
88
+ country = geo.get("country") or country_name(lat, lon) or "?"
89
+ self.border_title = f"Location — {country}"
90
+ self.border_subtitle = "+/- zoom"
91
+ self.update(render_map(lat, lon, cols=width, rows=height, zoom=self._zoom))
92
+
93
+
94
+ class StatusPanel(Static):
95
+ """Fixed status summary panel."""
96
+
97
+ def show_loading(self, ctx: ScanContext) -> None:
98
+ self.update(f"[dim]scanning {ctx.domain}…[/]")
99
+
100
+ def set_ctx(self, ctx: ScanContext, tech: list[str] | None = None) -> None:
101
+ self.update(render_status(ctx, tech))
@@ -0,0 +1,197 @@
1
+ """Country-level location map, rendered from embedded country borders.
2
+
3
+ Re-engineered from the old coarse box model: it loads low-resolution Natural
4
+ Earth country polygons (``data/countries.json``, built once from the 110m
5
+ dataset), auto-frames the view to the server's country plus surrounding
6
+ countries, and rasterises the real border lines into a braille grid with the
7
+ location marked. Projection is equirectangular with a cos(latitude) correction
8
+ so shapes aren't stretched. No zoom — the frame is chosen automatically.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import math
15
+ from functools import lru_cache
16
+ from pathlib import Path
17
+
18
+ from rich.text import Text
19
+
20
+ _DATA = Path(__file__).parent / "data" / "countries.json"
21
+ _CHAR_ASPECT = 2.0 # terminal cell height:width
22
+
23
+ # braille dot bit for a (row, col) within a 2-wide x 4-tall cell
24
+ _BITS = {
25
+ (0, 0): 0x01, (1, 0): 0x02, (2, 0): 0x04, (3, 0): 0x40,
26
+ (0, 1): 0x08, (1, 1): 0x10, (2, 1): 0x20, (3, 1): 0x80,
27
+ }
28
+
29
+ Ring = list[tuple[float, float]]
30
+ BBox = tuple[float, float, float, float] # minlon, minlat, maxlon, maxlat
31
+
32
+
33
+ @lru_cache(maxsize=1)
34
+ def _countries() -> list[tuple[str, list[tuple[Ring, BBox]]]]:
35
+ """Load countries as (name, [(ring, bbox), ...]); cached for the process."""
36
+ raw = json.loads(_DATA.read_text())
37
+ out = []
38
+ for country in raw:
39
+ rings = []
40
+ for ring in country["r"]:
41
+ pts = [(p[0], p[1]) for p in ring]
42
+ if len(pts) < 3:
43
+ continue
44
+ lons = [p[0] for p in pts]
45
+ lats = [p[1] for p in pts]
46
+ rings.append((pts, (min(lons), min(lats), max(lons), max(lats))))
47
+ out.append((country["n"], rings))
48
+ return out
49
+
50
+
51
+ def _point_in_ring(lon: float, lat: float, ring: Ring) -> bool:
52
+ inside = False
53
+ n = len(ring)
54
+ j = n - 1
55
+ for i in range(n):
56
+ xi, yi = ring[i]
57
+ xj, yj = ring[j]
58
+ if (yi > lat) != (yj > lat):
59
+ x_cross = (xj - xi) * (lat - yi) / (yj - yi) + xi
60
+ if lon < x_cross:
61
+ inside = not inside
62
+ j = i
63
+ return inside
64
+
65
+
66
+ def _containing_ring(lat: float, lon: float) -> tuple[str, BBox] | None:
67
+ """Return (country_name, bbox) of the specific landmass ring under (lat, lon)."""
68
+ for name, rings in _countries():
69
+ for ring, (mnx, mny, mxx, mxy) in rings:
70
+ if mnx <= lon <= mxx and mny <= lat <= mxy and _point_in_ring(lon, lat, ring):
71
+ return name, (mnx, mny, mxx, mxy)
72
+ return None
73
+
74
+
75
+ def render(
76
+ lat: float | None = None,
77
+ lon: float | None = None,
78
+ cols: int = 56,
79
+ rows: int = 14,
80
+ zoom: float = 1.0,
81
+ marker: str = "●",
82
+ marker_style: str = "bold green",
83
+ border_style: str = "grey58",
84
+ home_style: str = "grey85",
85
+ ) -> Text:
86
+ """Render an outline map framed on the server's country + neighbours.
87
+
88
+ ``zoom`` > 1 tightens the frame (closer), < 1 widens it (more surroundings).
89
+ """
90
+ cols, rows = max(8, cols), max(4, rows)
91
+ if lat is None or lon is None:
92
+ return Text("no location", style="dim")
93
+
94
+ hit = _containing_ring(lat, lon)
95
+ home_name = hit[0] if hit else None
96
+ if hit:
97
+ mnx, mny, mxx, mxy = hit[1]
98
+ else: # ocean / unmatched — frame a default box around the marker
99
+ mnx, mny, mxx, mxy = lon - 6, lat - 6, lon + 6, lat + 6
100
+
101
+ cx, cy = (mnx + mxx) / 2, (mny + mxy) / 2
102
+ # frame span: country size + margin, with a floor so tiny countries aren't over-zoomed
103
+ lon_span = max(mxx - mnx, 6.0) * 1.6
104
+ lat_span = max(mxy - mny, 4.0) * 1.6
105
+
106
+ # aspect fit (cos-corrected) — expand the deficient axis so nothing distorts
107
+ k = max(0.15, math.cos(math.radians(cy)))
108
+ target = cols / (2 * rows) # desired projected width:height for square pixels
109
+ if (lon_span * k) / lat_span < target:
110
+ lon_span = target * lat_span / k
111
+ else:
112
+ lat_span = (lon_span * k) / target
113
+
114
+ lon_span /= zoom # user zoom (both axes equally → aspect preserved)
115
+ lat_span /= zoom
116
+
117
+ # Keep the marker inside the frame (within the central 80%) at any zoom, by
118
+ # nudging the frame centre toward it — so zooming in never loses the point.
119
+ cx = min(max(cx, lon - lon_span * 0.4), lon + lon_span * 0.4)
120
+ cy = min(max(cy, lat - lat_span * 0.4), lat + lat_span * 0.4)
121
+
122
+ lon0, lat_top = cx - lon_span / 2, cy + lat_span / 2
123
+ pw, ph = cols * 2, rows * 4
124
+ view = (lon0, cy - lat_span / 2, lon0 + lon_span, lat_top) # for bbox culling
125
+
126
+ def to_px(lon_: float, lat_: float) -> tuple[float, float]:
127
+ return (lon_ - lon0) / lon_span * pw, (lat_top - lat_) / lat_span * ph
128
+
129
+ grid = [bytearray(pw) for _ in range(ph)] # 0 empty, 1 border, 2 home border
130
+
131
+ def plot(x: int, y: int, v: int) -> None:
132
+ if 0 <= x < pw and 0 <= y < ph and grid[y][x] < v:
133
+ grid[y][x] = v
134
+
135
+ def draw_line(x0: float, y0: float, x1: float, y1: float, v: int) -> None:
136
+ # integer Bresenham over the (possibly out-of-range) endpoints
137
+ xi0, yi0, xi1, yi1 = int(x0), int(y0), int(x1), int(y1)
138
+ dx, dy = abs(xi1 - xi0), -abs(yi1 - yi0)
139
+ sx = 1 if xi0 < xi1 else -1
140
+ sy = 1 if yi0 < yi1 else -1
141
+ err = dx + dy
142
+ while True:
143
+ plot(xi0, yi0, v)
144
+ if xi0 == xi1 and yi0 == yi1:
145
+ break
146
+ e2 = 2 * err
147
+ if e2 >= dy:
148
+ err += dy
149
+ xi0 += sx
150
+ if e2 <= dx:
151
+ err += dx
152
+ yi0 += sy
153
+
154
+ vmnx, vmny, vmxx, vmxy = view
155
+ for name, rings in _countries():
156
+ v = 2 if name == home_name else 1
157
+ for ring, (rmnx, rmny, rmxx, rmxy) in rings:
158
+ if rmxx < vmnx or rmnx > vmxx or rmxy < vmny or rmny > vmxy:
159
+ continue # ring entirely outside view
160
+ prev = ring[-1]
161
+ for cur in ring:
162
+ x0, y0 = to_px(*prev)
163
+ x1, y1 = to_px(*cur)
164
+ draw_line(x0, y0, x1, y1, v)
165
+ prev = cur
166
+
167
+ mpx, mpy = to_px(lon, lat)
168
+ mcx, mcy = int(mpx) // 2, int(mpy) // 4
169
+
170
+ text = Text()
171
+ for cyc in range(rows):
172
+ for cxc in range(cols):
173
+ if cxc == mcx and cyc == mcy:
174
+ text.append(marker, style=marker_style)
175
+ continue
176
+ bits = home = 0
177
+ for (r, c), bit in _BITS.items():
178
+ cell = grid[cyc * 4 + r][cxc * 2 + c]
179
+ if cell:
180
+ bits |= bit
181
+ if cell == 2:
182
+ home = 1
183
+ if bits:
184
+ text.append(chr(0x2800 + bits), style=home_style if home else border_style)
185
+ else:
186
+ text.append(" ")
187
+ if cyc != rows - 1:
188
+ text.append("\n")
189
+ return text
190
+
191
+
192
+ def country_name(lat: float | None, lon: float | None) -> str | None:
193
+ """Name of the country at (lat, lon), for the panel title (or None)."""
194
+ if lat is None or lon is None:
195
+ return None
196
+ hit = _containing_ring(lat, lon)
197
+ return hit[0] if hit else None