web-scanner 2.2.1__tar.gz → 2.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {web_scanner-2.2.1 → web_scanner-2.2.3}/PKG-INFO +19 -7
- {web_scanner-2.2.1 → web_scanner-2.2.3}/README.md +18 -6
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/__init__.py +1 -1
- web_scanner-2.2.3/webscanner/cli.py +36 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/colors.py +3 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/core/models.py +6 -2
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/dns.py +12 -1
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/sitemap.py +39 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/version_check.py +24 -6
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/activity.py +30 -3
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/app.py +101 -30
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/tables.py +5 -2
- web_scanner-2.2.1/webscanner/cli.py +0 -24
- {web_scanner-2.2.1 → web_scanner-2.2.3}/.gitignore +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/LICENSE +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/app.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/pyproject.toml +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/requirements.txt +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/__main__.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/core/__init__.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/core/context.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/core/module.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/core/scanner.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/helpers.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/__init__.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/headers.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/links.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/security.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/seo.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/ssl.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/subdomains.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/tech.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/modules/whois.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/__init__.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/agents.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/data/public_suffix_list.dat +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/http.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/net/psl.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/__init__.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/app.tcss +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/data/countries.json +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/export.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/widgets.py +0 -0
- {web_scanner-2.2.1 → web_scanner-2.2.3}/webscanner/ui/worldmap.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: web-scanner
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.3
|
|
4
4
|
Summary: An async Textual TUI for website reconnaissance — DNS, WHOIS, TLS, security, tech-stack and SEO, with no paid APIs.
|
|
5
5
|
Project-URL: Homepage, https://github.com/iamramizk/web-scanner
|
|
6
6
|
Project-URL: Repository, https://github.com/iamramizk/web-scanner
|
|
@@ -69,9 +69,7 @@ a server-status panel that stay pinned in place.
|
|
|
69
69
|
resolvers for blocklist checks, the system `whois`, and stdlib `ssl`/`socket`. Country
|
|
70
70
|
borders are embedded (Natural Earth), so even the map needs no tile service.
|
|
71
71
|
|
|
72
|
-

|
|
72
|
+

|
|
75
73
|
|
|
76
74
|
## Contents
|
|
77
75
|
|
|
@@ -130,7 +128,11 @@ Alongside the tabs, three fixed panels:
|
|
|
130
128
|
- **Server** — online status and response time, the final URL after redirects, IP,
|
|
131
129
|
geolocation, ISP, AS, hosting provider and detected CMS (name and version). When many
|
|
132
130
|
sites share the server's IP, it flags the address as shared and counts how many distinct
|
|
133
|
-
domains resolve to it (via a free reverse-IP lookup)
|
|
131
|
+
domains resolve to it (via a free reverse-IP lookup, [hackertarget](https://hackertarget.com/),
|
|
132
|
+
rate-limited to 50 requests/day).
|
|
133
|
+
|
|
134
|
+
The layout is **responsive**: on a narrow terminal it collapses to a single full-width
|
|
135
|
+
column and the map, Server and Activity panels fold into their own tabs.
|
|
134
136
|
|
|
135
137
|
Requests to the site being scanned wear a **coherent desktop-Chrome identity** — a real
|
|
136
138
|
User-Agent plus the headers Chrome actually sends beside it — picked once per scan and
|
|
@@ -174,11 +176,19 @@ webscan example.com
|
|
|
174
176
|
You can also run it as a module (`python -m webscanner example.com`), or from a source
|
|
175
177
|
checkout without installing (`python app.py example.com`).
|
|
176
178
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
Once a scan finishes you can stay in the app: press `r` to rescan the same domain, or `esc`
|
|
180
|
+
to edit the domain and scan a different one — no need to restart.
|
|
181
|
+
|
|
182
|
+
Press `s` to save every tab to CSV (plus a `server.csv` for the fixed Server panel, which
|
|
183
|
+
has no tab of its own). Files go into a `<domain>_<timestamp>/` folder — under `output/`
|
|
184
|
+
when running from a source checkout, or straight in your current directory when installed.
|
|
179
185
|
|
|
180
186
|
## Updating
|
|
181
187
|
|
|
188
|
+
The app checks PyPI in the background once a day and, when a newer release is out, marks the
|
|
189
|
+
version in the footer with an orange dot and adds a line to the Activity Log — so you'll know
|
|
190
|
+
when it's worth upgrading.
|
|
191
|
+
|
|
182
192
|
```bash
|
|
183
193
|
pipx upgrade web-scanner
|
|
184
194
|
```
|
|
@@ -195,6 +205,8 @@ pipx install --force git+https://github.com/iamramizk/web-scanner
|
|
|
195
205
|
| Key | Action |
|
|
196
206
|
| ---------------- | ------------------------------------------------------------------- |
|
|
197
207
|
| `←` / `→` `Tab` | Switch tabs |
|
|
208
|
+
| `1`–`9` `0` | Jump straight to the Nth tab (`0` = 10th) |
|
|
209
|
+
| `PgUp` / `PgDn` | Scroll the main panel up / down |
|
|
198
210
|
| `+` / `-` | Zoom the country map in / out |
|
|
199
211
|
| `↑` / `↓` `enter`| Navigate the Sitemap tree (`space` expands/collapses all) |
|
|
200
212
|
| `r` | Rescan |
|
|
@@ -19,9 +19,7 @@ a server-status panel that stay pinned in place.
|
|
|
19
19
|
resolvers for blocklist checks, the system `whois`, and stdlib `ssl`/`socket`. Country
|
|
20
20
|
borders are embedded (Natural Earth), so even the map needs no tile service.
|
|
21
21
|
|
|
22
|
-

|
|
22
|
+

|
|
25
23
|
|
|
26
24
|
## Contents
|
|
27
25
|
|
|
@@ -80,7 +78,11 @@ Alongside the tabs, three fixed panels:
|
|
|
80
78
|
- **Server** — online status and response time, the final URL after redirects, IP,
|
|
81
79
|
geolocation, ISP, AS, hosting provider and detected CMS (name and version). When many
|
|
82
80
|
sites share the server's IP, it flags the address as shared and counts how many distinct
|
|
83
|
-
domains resolve to it (via a free reverse-IP lookup)
|
|
81
|
+
domains resolve to it (via a free reverse-IP lookup, [hackertarget](https://hackertarget.com/),
|
|
82
|
+
rate-limited to 50 requests/day).
|
|
83
|
+
|
|
84
|
+
The layout is **responsive**: on a narrow terminal it collapses to a single full-width
|
|
85
|
+
column and the map, Server and Activity panels fold into their own tabs.
|
|
84
86
|
|
|
85
87
|
Requests to the site being scanned wear a **coherent desktop-Chrome identity** — a real
|
|
86
88
|
User-Agent plus the headers Chrome actually sends beside it — picked once per scan and
|
|
@@ -124,11 +126,19 @@ webscan example.com
|
|
|
124
126
|
You can also run it as a module (`python -m webscanner example.com`), or from a source
|
|
125
127
|
checkout without installing (`python app.py example.com`).
|
|
126
128
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
Once a scan finishes you can stay in the app: press `r` to rescan the same domain, or `esc`
|
|
130
|
+
to edit the domain and scan a different one — no need to restart.
|
|
131
|
+
|
|
132
|
+
Press `s` to save every tab to CSV (plus a `server.csv` for the fixed Server panel, which
|
|
133
|
+
has no tab of its own). Files go into a `<domain>_<timestamp>/` folder — under `output/`
|
|
134
|
+
when running from a source checkout, or straight in your current directory when installed.
|
|
129
135
|
|
|
130
136
|
## Updating
|
|
131
137
|
|
|
138
|
+
The app checks PyPI in the background once a day and, when a newer release is out, marks the
|
|
139
|
+
version in the footer with an orange dot and adds a line to the Activity Log — so you'll know
|
|
140
|
+
when it's worth upgrading.
|
|
141
|
+
|
|
132
142
|
```bash
|
|
133
143
|
pipx upgrade web-scanner
|
|
134
144
|
```
|
|
@@ -145,6 +155,8 @@ pipx install --force git+https://github.com/iamramizk/web-scanner
|
|
|
145
155
|
| Key | Action |
|
|
146
156
|
| ---------------- | ------------------------------------------------------------------- |
|
|
147
157
|
| `←` / `→` `Tab` | Switch tabs |
|
|
158
|
+
| `1`–`9` `0` | Jump straight to the Nth tab (`0` = 10th) |
|
|
159
|
+
| `PgUp` / `PgDn` | Scroll the main panel up / down |
|
|
148
160
|
| `+` / `-` | Zoom the country map in / out |
|
|
149
161
|
| `↑` / `↓` `enter`| Navigate the Sitemap tree (`space` expands/collapses all) |
|
|
150
162
|
| `r` | Rescan |
|
|
@@ -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
|
|
|
@@ -151,6 +151,12 @@ class DnsModule(ScanModule):
|
|
|
151
151
|
out[rtype] = res
|
|
152
152
|
return out
|
|
153
153
|
|
|
154
|
+
async def www_cname() -> list[str]:
|
|
155
|
+
# The apex almost never has a CNAME (forbidden alongside its SOA/NS), so
|
|
156
|
+
# the standard CNAME lookup above is usually empty. Hosting providers put
|
|
157
|
+
# the CNAME on `www.` instead — surface it when the apex has none.
|
|
158
|
+
return await asyncio.to_thread(pydig.query, f"www.{domain}", "CNAME")
|
|
159
|
+
|
|
154
160
|
async def dmarc() -> list[str]:
|
|
155
161
|
txt = await asyncio.to_thread(pydig.query, f"_dmarc.{domain}", "TXT")
|
|
156
162
|
return [t for t in txt if "dmarc1" in t.lower()]
|
|
@@ -159,12 +165,17 @@ class DnsModule(ScanModule):
|
|
|
159
165
|
txt = await asyncio.to_thread(pydig.query, f"{sel}._domainkey.{domain}", "TXT")
|
|
160
166
|
return sel if txt and _is_dkim(txt) else None
|
|
161
167
|
|
|
162
|
-
out, dmarc_records, dkim_hits = await asyncio.gather(
|
|
168
|
+
out, www, dmarc_records, dkim_hits = await asyncio.gather(
|
|
163
169
|
asyncio.to_thread(records),
|
|
170
|
+
www_cname(),
|
|
164
171
|
dmarc(),
|
|
165
172
|
asyncio.gather(*(dkim_selector(s) for s in DKIM_SELECTORS)),
|
|
166
173
|
)
|
|
167
174
|
|
|
175
|
+
# Show the www CNAME when the apex has none (the common hosting-provider case).
|
|
176
|
+
if www and "CNAME" not in out:
|
|
177
|
+
out["CNAME (www)"] = www
|
|
178
|
+
|
|
168
179
|
if dmarc_records:
|
|
169
180
|
out["DMARC"] = dmarc_records
|
|
170
181
|
found = [s for s in dkim_hits if s]
|
|
@@ -37,6 +37,32 @@ MAX_URLS = 10000
|
|
|
37
37
|
#: how deep a chain of nested <sitemapindex> files we follow
|
|
38
38
|
MAX_DEPTH = 6
|
|
39
39
|
|
|
40
|
+
#: File extensions that mark a sitemap ``<loc>`` as a downloadable **asset**
|
|
41
|
+
#: (image / media / document / archive / static file) rather than a page.
|
|
42
|
+
#: Classification is *pure parsing* — read off the URL path, never fetched.
|
|
43
|
+
#: Anything not listed here (no extension, ``.html``/``.php``, a dynamic route,
|
|
44
|
+
#: a trailing slash) counts as a page, so the bias is toward "page" and odd
|
|
45
|
+
#: dynamic URLs aren't mislabelled.
|
|
46
|
+
ASSET_EXTENSIONS = frozenset({
|
|
47
|
+
# images
|
|
48
|
+
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico",
|
|
49
|
+
"tif", "tiff", "avif", "heic", "heif",
|
|
50
|
+
# audio / video
|
|
51
|
+
"mp3", "mp4", "wav", "ogg", "oga", "webm", "mov", "avi", "mkv",
|
|
52
|
+
"flv", "m4a", "m4v", "wmv", "aac", "flac",
|
|
53
|
+
# documents
|
|
54
|
+
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods",
|
|
55
|
+
"odp", "rtf", "csv", "tsv", "epub",
|
|
56
|
+
# archives
|
|
57
|
+
"zip", "gz", "tgz", "tar", "rar", "7z", "bz2", "xz",
|
|
58
|
+
# web / static assets
|
|
59
|
+
"css", "js", "mjs", "map", "wasm", "rss", "atom",
|
|
60
|
+
# fonts
|
|
61
|
+
"woff", "woff2", "ttf", "otf", "eot",
|
|
62
|
+
# binaries / packages
|
|
63
|
+
"dmg", "exe", "apk", "pkg", "iso", "bin", "deb", "rpm", "msi",
|
|
64
|
+
})
|
|
65
|
+
|
|
40
66
|
|
|
41
67
|
class SitemapModule(ScanModule):
|
|
42
68
|
name = "sitemap"
|
|
@@ -158,6 +184,17 @@ def _localname(tag: str) -> str:
|
|
|
158
184
|
return tag.rsplit("}", 1)[-1].lower()
|
|
159
185
|
|
|
160
186
|
|
|
187
|
+
def _is_asset(url: str) -> bool:
|
|
188
|
+
"""True if `url`'s path names a downloadable asset (by file extension) rather
|
|
189
|
+
than a page. Pure parsing — no request is ever made. The extension is taken
|
|
190
|
+
from the last path segment only (query/fragment ignored); a missing or
|
|
191
|
+
unknown extension, or a trailing slash, counts as a page."""
|
|
192
|
+
last = urlparse(url).path.rsplit("/", 1)[-1]
|
|
193
|
+
if "." not in last:
|
|
194
|
+
return False
|
|
195
|
+
return last.rsplit(".", 1)[-1].lower() in ASSET_EXTENSIONS
|
|
196
|
+
|
|
197
|
+
|
|
161
198
|
def _segments(url: str, include_host: bool) -> list[str]:
|
|
162
199
|
"""Path segments for `url` as tree labels: ``/blog``, ``/post`` … An empty path
|
|
163
200
|
(the homepage) yields ``[]`` — it *is* the ``/`` root, not a child of it. When
|
|
@@ -196,6 +233,8 @@ def _url_tree(urls: list[str], truncated: bool) -> TreeNode:
|
|
|
196
233
|
if truncated:
|
|
197
234
|
root.children.append(TreeNode(label=f"… (truncated at {MAX_URLS} URLs)"))
|
|
198
235
|
root.total = len(urls)
|
|
236
|
+
root.assets = sum(1 for u in urls if _is_asset(u))
|
|
237
|
+
root.pages = root.total - root.assets
|
|
199
238
|
_sort_tree(root)
|
|
200
239
|
return root
|
|
201
240
|
|
|
@@ -62,11 +62,17 @@ def _write_cache(latest: str) -> None:
|
|
|
62
62
|
path.write_text(json.dumps({"latest": latest, "checked_at": time.time()}))
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
def
|
|
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
32
|
from ..colors import BLUE, GREEN, RED
|
|
33
|
+
from ..net.psl import registrable_domain
|
|
31
34
|
from ..core.context import ScanContext
|
|
32
35
|
from ..core.models import ModuleResult, ModuleStatus, ScanEvent
|
|
33
36
|
from ..core.scanner import PREFETCH, SHARED_IP
|
|
@@ -129,6 +132,20 @@ def _dns(result: ModuleResult) -> str:
|
|
|
129
132
|
return f"{line} · {', '.join(auth)}." if auth else f"{line}."
|
|
130
133
|
|
|
131
134
|
|
|
135
|
+
def _registrar_root(url: str | None) -> str:
|
|
136
|
+
"""Registrar's registrable domain from its WHOIS ``registrar_url``.
|
|
137
|
+
|
|
138
|
+
``http://www.hostinger.com`` → ``hostinger.com``. Tolerates a scheme-less
|
|
139
|
+
value (``www.hostinger.com``) — ``urlparse`` puts that in ``path``, not
|
|
140
|
+
``netloc``, so fall back to the whole string as the host. Returns "" when
|
|
141
|
+
nothing usable (no URL, or no registrable domain resolves)."""
|
|
142
|
+
if not url:
|
|
143
|
+
return ""
|
|
144
|
+
parsed = urlparse(url.strip())
|
|
145
|
+
host = parsed.netloc or parsed.path
|
|
146
|
+
return registrable_domain(host.split("/")[0].split("@")[-1])
|
|
147
|
+
|
|
148
|
+
|
|
132
149
|
def _whois(result: ModuleResult) -> str:
|
|
133
150
|
data = result.data or {}
|
|
134
151
|
if not data:
|
|
@@ -136,7 +153,11 @@ def _whois(result: ModuleResult) -> str:
|
|
|
136
153
|
if (note := _note(data)) is not None:
|
|
137
154
|
return f"{_DONE}. {_sentence(note)}"
|
|
138
155
|
bits = []
|
|
139
|
-
|
|
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:
|
|
@@ -340,7 +366,8 @@ def waf(result: ModuleResult) -> str | None:
|
|
|
340
366
|
|
|
341
367
|
def update_available(latest: str) -> str:
|
|
342
368
|
"""A newer release exists on PyPI. Emitted once, after the scan's own closing
|
|
343
|
-
line, only when a newer version was actually found — see net/version_check.py.
|
|
369
|
+
line, only when a newer version was actually found — see net/version_check.py.
|
|
370
|
+
Complements (does not replace) the status-bar version dot."""
|
|
344
371
|
return f"Update: [{BLUE}]v{_esc(latest)} available[/] — run `pipx upgrade web-scanner`."
|
|
345
372
|
|
|
346
373
|
|
|
@@ -25,10 +25,11 @@ from textual.message import Message
|
|
|
25
25
|
from textual.widgets import Input, LoadingIndicator, Static
|
|
26
26
|
|
|
27
27
|
from .. import __version__
|
|
28
|
+
from ..colors import ORANGE
|
|
28
29
|
from ..core import AsyncScanner, ModuleStatus, ScanContext, ScanEvent
|
|
29
30
|
from ..core.scanner import PREFETCH, SHARED_IP
|
|
30
31
|
from ..modules import all_modules
|
|
31
|
-
from ..net.version_check import
|
|
32
|
+
from ..net.version_check import update_status
|
|
32
33
|
from . import activity
|
|
33
34
|
from .export import export_csvs
|
|
34
35
|
from .tables import UNSET, render_result, render_status
|
|
@@ -38,6 +39,11 @@ from .widgets import ActivityLog, MapPanel, SitemapTree, StatusPanel, TabBar, Ta
|
|
|
38
39
|
_BAR_WIDTH = 22
|
|
39
40
|
_BAR_DIM = "grey30"
|
|
40
41
|
|
|
42
|
+
#: The status-bar version dot is shown only when a newer PyPI release is available
|
|
43
|
+
#: (`_version_status == "outdated"`; see net/version_check.py) — an orange `•`. The
|
|
44
|
+
#: initial/unknown and up-to-date states show no dot, keeping the footer quiet until
|
|
45
|
+
#: there's something to say.
|
|
46
|
+
|
|
41
47
|
#: below this terminal width the layout collapses to a single column (the fixed map
|
|
42
48
|
#: + Server panels become tabs, activity becomes a tab) — see _apply_narrow.
|
|
43
49
|
_NARROW_MAX = 90
|
|
@@ -50,19 +56,29 @@ _PSEUDO_NAMES = frozenset(name for name, _ in _PSEUDO_TABS)
|
|
|
50
56
|
_PSEUDO_LABELS = {name: label for name, label in _PSEUDO_TABS}
|
|
51
57
|
|
|
52
58
|
|
|
59
|
+
#: Wappalyzer categories that name the CMS, in priority order. ``CMS`` is the reliable
|
|
60
|
+
#: signal (the broader "Content" *group* also covers non-CMS tools). ``Page builders``
|
|
61
|
+
#: is a fallback for standalone site builders that carry no ``CMS`` tag (Webflow, Wix,
|
|
62
|
+
#: Squarespace, Duda, Framer). It is checked *only after* ``CMS`` comes up empty, because
|
|
63
|
+
#: the same category also holds WordPress *plugins* that are not the CMS (Elementor, Divi,
|
|
64
|
+
#: WPBakery) — on those sites Wappalyzer tags WordPress ``CMS`` and the first pass wins.
|
|
65
|
+
_CMS_CATEGORIES = ("CMS", "Page builders")
|
|
66
|
+
|
|
67
|
+
|
|
53
68
|
def _cms_from_tech(data: object) -> tuple[str, str | None] | None:
|
|
54
69
|
"""The CMS (name, version) from the Tech result, or ``None`` if none detected.
|
|
55
70
|
|
|
56
71
|
The Tech result is a ``Sections`` of per-group ``Grid``s whose rows are
|
|
57
|
-
``[name, categories, confidence, version]`` (see ``modules/tech.py``). A CMS is
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
", "-joined string; version is ``"-"`` when unknown →
|
|
72
|
+
``[name, categories, confidence, version]`` (see ``modules/tech.py``). A CMS is any
|
|
73
|
+
tech Wappalyzer tags with a category in ``_CMS_CATEGORIES`` — tried in order, so a
|
|
74
|
+
real ``CMS`` hit always beats a ``Page builders`` one across the whole result.
|
|
75
|
+
``categories`` is a ", "-joined string; version is ``"-"`` when unknown → ``None``.
|
|
61
76
|
"""
|
|
62
|
-
for
|
|
63
|
-
for
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
for target in _CMS_CATEGORIES:
|
|
78
|
+
for section in data or []:
|
|
79
|
+
for name, categories, _confidence, version in section.data:
|
|
80
|
+
if target in [c.strip() for c in str(categories).split(",")]:
|
|
81
|
+
return name, (version if version and version != "-" else None)
|
|
66
82
|
return None
|
|
67
83
|
|
|
68
84
|
|
|
@@ -111,12 +127,12 @@ def _same_cms(left: str, right: str) -> bool:
|
|
|
111
127
|
def _detect_cms(tech_data: object, html: str | None) -> tuple[str, str | None] | None:
|
|
112
128
|
"""The CMS (name, version) for the Server panel, or ``None`` if nothing detected.
|
|
113
129
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
Sitefinity
|
|
118
|
-
|
|
119
|
-
|
|
130
|
+
``_cms_from_tech`` is the primary signal (Wappalyzer's ``CMS`` category, then a
|
|
131
|
+
``Page builders`` fallback — see there). The ``<meta name="generator">`` tag covers
|
|
132
|
+
the two cases it misses: a CMS Wappalyzer has no fingerprint for at all (e.g.
|
|
133
|
+
Sitefinity) → the generator is used outright; and a CMS it detects but can't version
|
|
134
|
+
→ the generator supplies the version, but only when it names the *same* product, so
|
|
135
|
+
an "Elementor 3.x" generator can't hijack a "WordPress" hit.
|
|
120
136
|
"""
|
|
121
137
|
tech = _cms_from_tech(tech_data)
|
|
122
138
|
generators = _generators(html)
|
|
@@ -133,6 +149,20 @@ def _detect_cms(tech_data: object, html: str | None) -> tuple[str, str | None] |
|
|
|
133
149
|
return tech
|
|
134
150
|
|
|
135
151
|
|
|
152
|
+
def _sitemap_subtitle(root: object) -> str:
|
|
153
|
+
"""The Sitemap tab's ``#main`` subtitle: ``"12 pages • 3 assets"`` (pages vs.
|
|
154
|
+
assets split purely by URL extension — see ``sitemap._is_asset``). Assets are
|
|
155
|
+
only shown when present; falls back to the raw total if the split is missing."""
|
|
156
|
+
pages = getattr(root, "pages", None)
|
|
157
|
+
assets = getattr(root, "assets", None) or 0
|
|
158
|
+
if pages is None: # older/other tree without the split — show the plain count
|
|
159
|
+
return f"{getattr(root, 'total', 0) or 0} Total"
|
|
160
|
+
parts = [f"{pages} {'page' if pages == 1 else 'pages'}"]
|
|
161
|
+
if assets:
|
|
162
|
+
parts.append(f"{assets} {'asset' if assets == 1 else 'assets'}")
|
|
163
|
+
return " • ".join(parts)
|
|
164
|
+
|
|
165
|
+
|
|
136
166
|
class ScanProgress(Message):
|
|
137
167
|
"""A ScanEvent surfaced onto the Textual message pump."""
|
|
138
168
|
|
|
@@ -146,9 +176,15 @@ class ScanFinished(Message):
|
|
|
146
176
|
|
|
147
177
|
|
|
148
178
|
class VersionChecked(Message):
|
|
149
|
-
"""Result of the background PyPI update check (see net/version_check.py).
|
|
179
|
+
"""Result of the background PyPI update check (see net/version_check.py).
|
|
150
180
|
|
|
151
|
-
|
|
181
|
+
``status`` is one of ``"unknown"`` / ``"latest"`` / ``"outdated"`` — it drives
|
|
182
|
+
the colour of the status-bar version dot. ``latest`` is the newer version string
|
|
183
|
+
when ``status == "outdated"`` (else None) — used for the Activity Log line.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
def __init__(self, status: str, latest: str | None) -> None:
|
|
187
|
+
self.status = status
|
|
152
188
|
self.latest = latest
|
|
153
189
|
super().__init__()
|
|
154
190
|
|
|
@@ -212,8 +248,12 @@ class WebScannerApp(App):
|
|
|
212
248
|
# last result rendered into the sitemap Tree, so switching tabs doesn't
|
|
213
249
|
# rebuild (and re-collapse) it every visit.
|
|
214
250
|
self._tree_result = None
|
|
215
|
-
# background PyPI update check (see _check_version) —
|
|
216
|
-
#
|
|
251
|
+
# background PyPI update check (see _check_version) — drives the colour of the
|
|
252
|
+
# status-bar version dot. "unknown" (blue) until the check answers.
|
|
253
|
+
self._version_status = "unknown"
|
|
254
|
+
# …and, when a newer release exists, an Activity Log line announced once after
|
|
255
|
+
# whichever finishes last: the scan or the check itself (the dot is not enough
|
|
256
|
+
# on its own — the log line spells out the upgrade command).
|
|
217
257
|
self._update_available: str | None = None
|
|
218
258
|
self._version_checked = False
|
|
219
259
|
self._update_announced = False
|
|
@@ -246,6 +286,9 @@ class WebScannerApp(App):
|
|
|
246
286
|
self.query_one("#tabs", TabBar).set_selected(self.selected)
|
|
247
287
|
# Establish the layout (wide vs narrow) before the first paint / scan.
|
|
248
288
|
self._apply_narrow(self.size.width < _NARROW_MAX)
|
|
289
|
+
# Show the version + (blue) update dot straight away; the dot recolours once
|
|
290
|
+
# the background check answers, and the progress bar reclaims the slot mid-scan.
|
|
291
|
+
self._refresh_version()
|
|
249
292
|
# own group: the scan worker below is exclusive=True, which cancels every
|
|
250
293
|
# other worker in its group — a shared/default group would kill this one.
|
|
251
294
|
self.run_worker(
|
|
@@ -288,15 +331,22 @@ class WebScannerApp(App):
|
|
|
288
331
|
self._set_keybar(editing=editing)
|
|
289
332
|
|
|
290
333
|
async def _check_version(self) -> None:
|
|
291
|
-
latest = await asyncio.to_thread(
|
|
292
|
-
self.post_message(VersionChecked(latest))
|
|
334
|
+
status, latest = await asyncio.to_thread(update_status, __version__)
|
|
335
|
+
self.post_message(VersionChecked(status, latest))
|
|
293
336
|
|
|
294
337
|
def on_version_checked(self, message: VersionChecked) -> None:
|
|
338
|
+
self._version_status = message.status
|
|
295
339
|
self._update_available = message.latest
|
|
296
340
|
self._version_checked = True
|
|
341
|
+
self._refresh_version()
|
|
297
342
|
self._maybe_announce_update()
|
|
298
343
|
|
|
299
344
|
def _maybe_announce_update(self) -> None:
|
|
345
|
+
"""Append the "update available" Activity Log line once, after the scan's own
|
|
346
|
+
closing line — so it lands as the last message. Gated on the scan being done
|
|
347
|
+
(otherwise it would jump ahead of the module lines) and on a newer release
|
|
348
|
+
actually existing; the check may finish before or after the scan, so both
|
|
349
|
+
on_version_checked and on_scan_finished call this."""
|
|
300
350
|
if (
|
|
301
351
|
self._update_announced
|
|
302
352
|
or self._scanning
|
|
@@ -309,6 +359,16 @@ class WebScannerApp(App):
|
|
|
309
359
|
activity.update_available(self._update_available)
|
|
310
360
|
)
|
|
311
361
|
|
|
362
|
+
def _refresh_version(self) -> None:
|
|
363
|
+
"""Draw the version + update dot at the right of the footer, unless a scan is
|
|
364
|
+
in progress (then the progress bar owns that slot — restored on finish)."""
|
|
365
|
+
if self._scanning:
|
|
366
|
+
return
|
|
367
|
+
# Only show the dot when an update is available (hide the initial blue /
|
|
368
|
+
# up-to-date green states) — a quiet footer until there's something to say.
|
|
369
|
+
dot = f" [{ORANGE}]•[/]" if self._version_status == "outdated" else ""
|
|
370
|
+
self.query_one("#progress", Static).update(f"v{__version__}{dot}")
|
|
371
|
+
|
|
312
372
|
# ---- scanning ---------------------------------------------------------
|
|
313
373
|
|
|
314
374
|
def start_scan(self, target: str) -> None:
|
|
@@ -415,14 +475,14 @@ class WebScannerApp(App):
|
|
|
415
475
|
self.completed, self.failed, total, time.monotonic() - self._scan_start
|
|
416
476
|
)
|
|
417
477
|
)
|
|
418
|
-
self.
|
|
478
|
+
self._refresh_version()
|
|
419
479
|
self._maybe_announce_update()
|
|
420
480
|
|
|
421
481
|
# ---- progress line ----------------------------------------------------
|
|
422
482
|
|
|
423
483
|
def _update_progress(self) -> None:
|
|
424
484
|
"""Draw a determinate progress bar (blue = done, grey = remaining) with the
|
|
425
|
-
percentage
|
|
485
|
+
percentage in white, right-aligned on the footer row."""
|
|
426
486
|
if not self._scanning:
|
|
427
487
|
return
|
|
428
488
|
total = len(self.modules)
|
|
@@ -431,7 +491,7 @@ class WebScannerApp(App):
|
|
|
431
491
|
blue = self.current_theme.primary
|
|
432
492
|
bar = f"[{blue}]{'━' * filled}[/][{_BAR_DIM}]{'━' * (_BAR_WIDTH - filled)}[/]"
|
|
433
493
|
self.query_one("#progress", Static).update(
|
|
434
|
-
f"{bar} [white]{round(frac * 100)}%
|
|
494
|
+
f"{bar} [white]{round(frac * 100)}%[/]"
|
|
435
495
|
)
|
|
436
496
|
|
|
437
497
|
# ---- tab selection ----------------------------------------------------
|
|
@@ -542,9 +602,8 @@ class WebScannerApp(App):
|
|
|
542
602
|
if self._tree_result is not result:
|
|
543
603
|
tree.populate(result.data)
|
|
544
604
|
self._tree_result = result
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
self.query_one("#main").border_subtitle = f"{total} Total"
|
|
605
|
+
if result.data.total is not None:
|
|
606
|
+
self.query_one("#main").border_subtitle = _sitemap_subtitle(result.data)
|
|
548
607
|
elif result.status is ModuleStatus.FAILED:
|
|
549
608
|
self._set_main(f"[red]failed:[/] {result.error}")
|
|
550
609
|
elif result.status is ModuleStatus.EMPTY:
|
|
@@ -653,11 +712,19 @@ class WebScannerApp(App):
|
|
|
653
712
|
def action_zoom_out(self) -> None:
|
|
654
713
|
self.query_one("#map", MapPanel).zoom_by(-1)
|
|
655
714
|
|
|
715
|
+
def _page_target(self):
|
|
716
|
+
"""The widget page-up/down should scroll. Usually the main panel, but the
|
|
717
|
+
Activity pseudo-tab (narrow only) shows the #activity RichLog full-height in
|
|
718
|
+
#main's place, so page keys must scroll that instead — #main is hidden there."""
|
|
719
|
+
if self._narrow and self.selected == "activity":
|
|
720
|
+
return self.query_one("#activity", ActivityLog)
|
|
721
|
+
return self.query_one("#main", VerticalScroll)
|
|
722
|
+
|
|
656
723
|
def action_scroll_main_up(self) -> None:
|
|
657
|
-
self.
|
|
724
|
+
self._page_target().scroll_page_up()
|
|
658
725
|
|
|
659
726
|
def action_scroll_main_down(self) -> None:
|
|
660
|
-
self.
|
|
727
|
+
self._page_target().scroll_page_down()
|
|
661
728
|
|
|
662
729
|
def on_input_submitted(self, message: Input.Submitted) -> None:
|
|
663
730
|
target = message.value.strip()
|
|
@@ -685,6 +752,10 @@ class WebScannerApp(App):
|
|
|
685
752
|
# Compact set — the full labels overflow a phone-width footer.
|
|
686
753
|
pairs = [("←/→", "Tab"), ("r", "Scan"), ("s", "Save"), ("q", "Quit")]
|
|
687
754
|
else:
|
|
688
|
-
|
|
755
|
+
# PgUp/Dn scrolls #main — only shown wide; the narrow footer has no room.
|
|
756
|
+
pairs = [
|
|
757
|
+
("q", "Quit"), ("←/→", "Tab"), ("Pg↑/↓", "Scroll"),
|
|
758
|
+
("r", "Rescan"), ("s", "Save"), ("esc", "Edit domain"),
|
|
759
|
+
]
|
|
689
760
|
text = " ".join(f"[b {c}]{k}[/] {label}" for k, label in pairs)
|
|
690
761
|
self.query_one("#keybar", Static).update(text)
|
|
@@ -37,8 +37,9 @@ TAB_HEADERS: dict[str, tuple[str, str]] = {
|
|
|
37
37
|
MAX_KEY_WIDTH = 34
|
|
38
38
|
|
|
39
39
|
# Tabs whose key column is smart-cased (Title Case, acronyms kept upper) instead of
|
|
40
|
-
# ALL CAPS. dns
|
|
41
|
-
|
|
40
|
+
# ALL CAPS. dns is smart too — its keys are mostly record-type acronyms (kept upper via
|
|
41
|
+
# _ACRONYMS), but derived rows read better Title-cased ("Email Spoofing", "CNAME (www)").
|
|
42
|
+
_SMART_LABEL_TABS = {"dns", "whois", "ssl", "headers"}
|
|
42
43
|
|
|
43
44
|
# Tokens that render upper-case rather than Title-cased. Lowercase keys. Only needs
|
|
44
45
|
# the acronyms that actually appear as key tokens in the smart-cased tabs — NOT an
|
|
@@ -49,6 +50,8 @@ _ACRONYMS: frozenset[str] = frozenset({
|
|
|
49
50
|
"id", "url", "iana", "whois", "dnssec",
|
|
50
51
|
# ssl
|
|
51
52
|
"cn", "san", "ssl", "tls",
|
|
53
|
+
# dns record types (keep upper on the smart-cased dns tab)
|
|
54
|
+
"a", "aaaa", "ns", "cname", "soa", "mx", "txt", "caa", "ds", "dnskey",
|
|
52
55
|
# headers / general web acronyms
|
|
53
56
|
"xss", "csp", "hsts", "cors", "www", "ua", "md5", "ip", "dns",
|
|
54
57
|
"http", "https", "uri", "api", "spf", "dkim", "dmarc",
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
"""Console entry point for the ``webscan`` command.
|
|
2
|
-
|
|
3
|
-
webscan example.com
|
|
4
|
-
python -m webscanner example.com
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import sys
|
|
10
|
-
|
|
11
|
-
from webscanner.helpers import is_valid_url
|
|
12
|
-
from webscanner.ui.app import WebScannerApp
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def main() -> None:
|
|
16
|
-
target = sys.argv[1].strip() if len(sys.argv) > 1 else None
|
|
17
|
-
if target and not is_valid_url(target):
|
|
18
|
-
print(f"[!] Not a valid target: {target}")
|
|
19
|
-
raise SystemExit(1)
|
|
20
|
-
WebScannerApp(target).run()
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if __name__ == "__main__":
|
|
24
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|