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.
- web_scanner-2.0.0.dist-info/METADATA +803 -0
- web_scanner-2.0.0.dist-info/RECORD +35 -0
- web_scanner-2.0.0.dist-info/WHEEL +4 -0
- web_scanner-2.0.0.dist-info/entry_points.txt +2 -0
- web_scanner-2.0.0.dist-info/licenses/LICENSE +674 -0
- webscanner/__init__.py +3 -0
- webscanner/__main__.py +6 -0
- webscanner/cli.py +24 -0
- webscanner/colors.py +4 -0
- webscanner/core/__init__.py +15 -0
- webscanner/core/context.py +40 -0
- webscanner/core/models.py +79 -0
- webscanner/core/module.py +27 -0
- webscanner/core/scanner.py +103 -0
- webscanner/helpers.py +41 -0
- webscanner/modules/__init__.py +38 -0
- webscanner/modules/dns.py +96 -0
- webscanner/modules/headers.py +16 -0
- webscanner/modules/links.py +69 -0
- webscanner/modules/security.py +124 -0
- webscanner/modules/seo.py +148 -0
- webscanner/modules/ssl.py +57 -0
- webscanner/modules/subdomains.py +56 -0
- webscanner/modules/tech.py +43 -0
- webscanner/modules/whois.py +114 -0
- webscanner/net/__init__.py +5 -0
- webscanner/net/http.py +66 -0
- webscanner/ui/__init__.py +1 -0
- webscanner/ui/app.py +287 -0
- webscanner/ui/app.tcss +93 -0
- webscanner/ui/data/countries.json +1 -0
- webscanner/ui/export.py +113 -0
- webscanner/ui/tables.py +256 -0
- webscanner/ui/widgets.py +101 -0
- webscanner/ui/worldmap.py +197 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Security module — three tables: Open Ports, HTTP Security, Blocklists.
|
|
2
|
+
|
|
3
|
+
- Open Ports: TCP-connect scan of a short list of common ports.
|
|
4
|
+
- HTTP Security: presence of key response security headers (from prefetch).
|
|
5
|
+
- Blocklists: whether the domain is blocked by popular filtering DNS resolvers
|
|
6
|
+
(AdGuard, CleanBrowsing, Cloudflare, Google, OpenDNS, Quad9), queried directly
|
|
7
|
+
by IP via ``dig``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
|
|
14
|
+
from ..colors import GREEN, RED
|
|
15
|
+
from ..core.module import ScanModule
|
|
16
|
+
from ..core.context import ScanContext
|
|
17
|
+
from ..core.models import Section, Sections
|
|
18
|
+
|
|
19
|
+
# port -> service label (short common set)
|
|
20
|
+
COMMON_PORTS: dict[int, str] = {
|
|
21
|
+
21: "FTP", 22: "SSH", 25: "SMTP", 53: "DNS", 80: "HTTP",
|
|
22
|
+
443: "HTTPS", 3306: "MySQL", 3389: "RDP", 8080: "HTTP-alt", 8443: "HTTPS-alt",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# response header -> short label shown in the table
|
|
26
|
+
SEC_HEADERS: dict[str, str] = {
|
|
27
|
+
"content-security-policy": "Content-Security-Policy",
|
|
28
|
+
"strict-transport-security": "Strict-Transport-Security",
|
|
29
|
+
"x-content-type-options": "X-Content-Type-Options",
|
|
30
|
+
"x-frame-options": "X-Frame-Options",
|
|
31
|
+
"referrer-policy": "Referrer-Policy",
|
|
32
|
+
"permissions-policy": "Permissions-Policy",
|
|
33
|
+
"cross-origin-opener-policy": "Cross-Origin-Opener-Policy",
|
|
34
|
+
"cross-origin-embedder-policy": "Cross-Origin-Embedder-Policy",
|
|
35
|
+
"cross-origin-resource-policy": "Cross-Origin-Resource-Policy",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# filtering DNS resolver -> IP (we ask each whether it blocks the domain)
|
|
39
|
+
FILTERS: dict[str, str] = {
|
|
40
|
+
"AdGuard": "94.140.14.14",
|
|
41
|
+
"AdGuard Family": "94.140.14.15",
|
|
42
|
+
"CleanBrowsing Adult": "185.228.168.10",
|
|
43
|
+
"CleanBrowsing Family": "185.228.168.168",
|
|
44
|
+
"CleanBrowsing Security": "185.228.168.9",
|
|
45
|
+
"CloudFlare": "1.1.1.1",
|
|
46
|
+
"CloudFlare Family": "1.1.1.3",
|
|
47
|
+
"Google DNS": "8.8.8.8",
|
|
48
|
+
"OpenDNS": "208.67.222.222",
|
|
49
|
+
"OpenDNS Family": "208.67.222.123",
|
|
50
|
+
"Quad9": "9.9.9.9",
|
|
51
|
+
}
|
|
52
|
+
# answer IPs that mean "blocked/sinkhole" rather than a real result
|
|
53
|
+
_BLOCK_PREFIXES = ("0.0.0.0", "146.112.61.", "146.112.255.", "208.69.38.", "208.69.39.", "::")
|
|
54
|
+
|
|
55
|
+
_YES, _NO = f"[{GREEN}]Yes[/]", f"[{RED}]No[/]"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def _port_open(ip: str, port: int) -> bool:
|
|
59
|
+
try:
|
|
60
|
+
_, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=1.5)
|
|
61
|
+
writer.close()
|
|
62
|
+
try:
|
|
63
|
+
await writer.wait_closed()
|
|
64
|
+
except Exception: # noqa: BLE001
|
|
65
|
+
pass
|
|
66
|
+
return True
|
|
67
|
+
except Exception: # noqa: BLE001
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _scan_ports(ip: str) -> dict[str, str]:
|
|
72
|
+
opens = await asyncio.gather(*(_port_open(ip, p) for p in COMMON_PORTS))
|
|
73
|
+
return {
|
|
74
|
+
f"{port} ({svc})": f"[{GREEN}]open[/]" if is_open else "[dim]closed[/]"
|
|
75
|
+
for (port, svc), is_open in zip(COMMON_PORTS.items(), opens)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _http_security(headers: dict[str, str]) -> dict[str, str]:
|
|
80
|
+
present = {k.lower() for k in headers}
|
|
81
|
+
return {label: (_YES if h in present else _NO) for h, label in SEC_HEADERS.items()}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def _query_filter(domain: str, ip: str) -> str:
|
|
85
|
+
"""Ask a filtering resolver for the domain; classify Blocked/Not Blocked."""
|
|
86
|
+
try:
|
|
87
|
+
proc = await asyncio.create_subprocess_exec(
|
|
88
|
+
"dig", "+short", "+time=2", "+tries=1", f"@{ip}", domain, "A",
|
|
89
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
|
|
90
|
+
)
|
|
91
|
+
out, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
|
|
92
|
+
answers = [ln.strip() for ln in out.decode(errors="replace").splitlines() if ln.strip()]
|
|
93
|
+
if not answers: # NXDOMAIN / refused / empty
|
|
94
|
+
return f"[{RED}]Blocked[/]"
|
|
95
|
+
if any(a.startswith(_BLOCK_PREFIXES) for a in answers):
|
|
96
|
+
return f"[{RED}]Blocked[/]"
|
|
97
|
+
return f"[{GREEN}]Not Blocked[/]"
|
|
98
|
+
except Exception: # noqa: BLE001
|
|
99
|
+
return "[dim]error[/]"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _blocklists(domain: str) -> dict[str, str]:
|
|
103
|
+
names = list(FILTERS)
|
|
104
|
+
outcomes = await asyncio.gather(*(_query_filter(domain, FILTERS[n]) for n in names))
|
|
105
|
+
return dict(zip(names, outcomes))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SecurityModule(ScanModule):
|
|
109
|
+
name = "security"
|
|
110
|
+
label = "Security"
|
|
111
|
+
|
|
112
|
+
async def run(self, ctx: ScanContext) -> Sections:
|
|
113
|
+
ports_coro = _scan_ports(ctx.ip) if ctx.ip else _empty()
|
|
114
|
+
ports, blocks = await asyncio.gather(ports_coro, _blocklists(ctx.domain))
|
|
115
|
+
r = (3, 2) # 60/40 columns across all three tables
|
|
116
|
+
return Sections([
|
|
117
|
+
Section("Open Ports", ports or {"note": "no IP to scan"}, ("Port", "Status"), ratio=r),
|
|
118
|
+
Section("HTTP Security", _http_security(ctx.headers), ("Header", "Present"), ratio=r),
|
|
119
|
+
Section("Blocklists", blocks, ("Blocklist", "Status"), ratio=r),
|
|
120
|
+
])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _empty() -> dict[str, str]:
|
|
124
|
+
return {}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""SEO module — Content, Keywords, Robots, and Schema tables.
|
|
2
|
+
|
|
3
|
+
- Content: title / description (each with a character-count + recommended-length
|
|
4
|
+
hint, green when in range, red when out), h1–h3 headings + socials.
|
|
5
|
+
- Keywords: the top-10 most frequent 1-, 2- and 3-word phrases on the page.
|
|
6
|
+
- Robots: robots.txt presence, any sitemaps, and the raw file.
|
|
7
|
+
- Schema: whether the page has schema.org structured data (JSON-LD) + the parsed
|
|
8
|
+
JSON (shown last, in full).
|
|
9
|
+
|
|
10
|
+
Parses the HTML fetched once during prefetch; robots.txt is a small extra fetch.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
from collections import Counter
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
from bs4 import BeautifulSoup
|
|
23
|
+
from rich.markup import escape
|
|
24
|
+
|
|
25
|
+
from ..colors import GREEN, RED
|
|
26
|
+
from ..core.module import ScanModule
|
|
27
|
+
from ..core.context import ScanContext
|
|
28
|
+
from ..core.models import Section, Sections
|
|
29
|
+
from ..net.http import DEFAULT_HEADERS, TIMEOUT
|
|
30
|
+
|
|
31
|
+
TITLE_RANGE = (30, 60)
|
|
32
|
+
DESC_RANGE = (70, 160)
|
|
33
|
+
|
|
34
|
+
SOCIAL_DOMAINS = (
|
|
35
|
+
"facebook.com", "twitter.com", "x.com", "instagram.com", "linkedin.com",
|
|
36
|
+
"youtube.com", "youtu.be", "pinterest.com", "tiktok.com", "reddit.com",
|
|
37
|
+
"medium.com", "discord.com", "discord.gg", "twitch.tv", "vimeo.com",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_STOPWORDS = frozenset(
|
|
41
|
+
"the a an and or but if of to in on for with as by at from is are was were be "
|
|
42
|
+
"been being this that these those it its you your we our us they their he she "
|
|
43
|
+
"his her not no so do does did has have had can will would should could may "
|
|
44
|
+
"might just than then there here what which who when where how all any some "
|
|
45
|
+
"more most other into out up down over under about also new get one two".split()
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _len_line(text: str, lo: int, hi: int) -> str:
|
|
50
|
+
n = len(text)
|
|
51
|
+
colour = GREEN if lo <= n <= hi else RED
|
|
52
|
+
return f"[dim]{escape(text)}[/]\n[{colour}]{n} chars · rec. {lo}-{hi}[/]"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SeoModule(ScanModule):
|
|
56
|
+
name = "seo"
|
|
57
|
+
label = "SEO"
|
|
58
|
+
|
|
59
|
+
async def run(self, ctx: ScanContext) -> Sections:
|
|
60
|
+
robots_coro = asyncio.to_thread(self._fetch_robots, ctx.domain)
|
|
61
|
+
if not ctx.html:
|
|
62
|
+
robots = await robots_coro
|
|
63
|
+
note = {"note": "no page content"}
|
|
64
|
+
schema, content, keywords = {"Has Schema": f"[{RED}]No[/]"}, note, note
|
|
65
|
+
else:
|
|
66
|
+
parsed, robots = await asyncio.gather(
|
|
67
|
+
asyncio.to_thread(self._parse, ctx), robots_coro
|
|
68
|
+
)
|
|
69
|
+
schema, content, keywords = parsed
|
|
70
|
+
|
|
71
|
+
return Sections([
|
|
72
|
+
Section("Content", content, ("Field", "Value"), spaced=True),
|
|
73
|
+
Section("Keywords", keywords, ("N-gram", "Top 10 (by frequency)"), spaced=True),
|
|
74
|
+
Section("Robots", robots, ("Field", "Value"), spaced=True),
|
|
75
|
+
Section("Schema", schema, ("Field", "Value"), spaced=True),
|
|
76
|
+
])
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _parse(ctx: ScanContext) -> tuple[dict, dict, dict]:
|
|
80
|
+
soup = BeautifulSoup(ctx.html, "html.parser")
|
|
81
|
+
|
|
82
|
+
# --- Schema (JSON-LD structured data) ---
|
|
83
|
+
blocks = []
|
|
84
|
+
for tag in soup.find_all("script", attrs={"type": "application/ld+json"}):
|
|
85
|
+
try:
|
|
86
|
+
blocks.append(json.loads(tag.string or tag.get_text()))
|
|
87
|
+
except Exception: # noqa: BLE001
|
|
88
|
+
pass
|
|
89
|
+
schema: dict[str, str] = {"Has Schema": f"[{GREEN}]Yes[/]" if blocks else f"[{RED}]No[/]"}
|
|
90
|
+
if blocks:
|
|
91
|
+
schema["Schema"] = json.dumps(
|
|
92
|
+
blocks[0] if len(blocks) == 1 else blocks, indent=2, ensure_ascii=False
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# --- Content ---
|
|
96
|
+
title_el = soup.find("title")
|
|
97
|
+
title = title_el.get_text(strip=True) if title_el else None
|
|
98
|
+
desc_el = soup.find("meta", attrs={"name": "description"})
|
|
99
|
+
desc = (desc_el.get("content") or "").strip() if desc_el else None
|
|
100
|
+
content: dict[str, object] = {
|
|
101
|
+
"Title": _len_line(title, *TITLE_RANGE) if title else "-",
|
|
102
|
+
"Description": _len_line(desc, *DESC_RANGE) if desc else "-",
|
|
103
|
+
}
|
|
104
|
+
for lvl in range(1, 4):
|
|
105
|
+
content[f"H{lvl}"] = [h.get_text(strip=True) for h in soup.find_all(f"h{lvl}")]
|
|
106
|
+
content["Socials"] = sorted({
|
|
107
|
+
tag["href"].rstrip("/")
|
|
108
|
+
for tag in soup.find_all("a", href=True)
|
|
109
|
+
if any(urlparse(tag["href"]).netloc.lower().endswith(d) for d in SOCIAL_DOMAINS)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
# --- Keywords (n-grams) ---
|
|
113
|
+
for tag in soup(["script", "style", "noscript"]):
|
|
114
|
+
tag.decompose()
|
|
115
|
+
text = soup.get_text(" ", strip=True).lower()
|
|
116
|
+
tokens = [t for t in re.findall(r"[a-z][a-z'-]{2,}", text) if t not in _STOPWORDS]
|
|
117
|
+
|
|
118
|
+
def top(n: int) -> str:
|
|
119
|
+
grams = (" ".join(tokens[i:i + n]) for i in range(len(tokens) - n + 1))
|
|
120
|
+
return ", ".join(g for g, _ in Counter(grams).most_common(10)) or "-"
|
|
121
|
+
|
|
122
|
+
keywords = {"1-word": top(1), "2-word": top(2), "3-word": top(3)}
|
|
123
|
+
return schema, content, keywords
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _fetch_robots(domain: str) -> dict[str, object]:
|
|
127
|
+
try:
|
|
128
|
+
resp = requests.get(
|
|
129
|
+
f"https://{domain}/robots.txt",
|
|
130
|
+
headers=DEFAULT_HEADERS, timeout=TIMEOUT, allow_redirects=True,
|
|
131
|
+
)
|
|
132
|
+
text = resp.text.strip()
|
|
133
|
+
ctype = resp.headers.get("content-type", "").lower()
|
|
134
|
+
looks_html = "html" in ctype or "<html" in text[:200].lower() or text[:20].lower().startswith("<!doctype")
|
|
135
|
+
if resp.status_code == 200 and text and not looks_html:
|
|
136
|
+
result: dict[str, object] = {"Found": f"[{GREEN}]Yes[/]"}
|
|
137
|
+
sitemaps = [
|
|
138
|
+
ln.split(":", 1)[1].strip()
|
|
139
|
+
for ln in text.splitlines()
|
|
140
|
+
if ln.strip().lower().startswith("sitemap:")
|
|
141
|
+
]
|
|
142
|
+
if sitemaps:
|
|
143
|
+
result["Sitemaps"] = sitemaps
|
|
144
|
+
result["robots.txt"] = text
|
|
145
|
+
return result
|
|
146
|
+
except Exception: # noqa: BLE001
|
|
147
|
+
pass
|
|
148
|
+
return {"Found": f"[{RED}]No[/]"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""SSL/TLS certificate module (uses the cert grabbed in prefetch)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..core.module import ScanModule
|
|
9
|
+
from ..core.context import ScanContext
|
|
10
|
+
|
|
11
|
+
_CERT_DATE = "%b %d %H:%M:%S %Y %Z"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _flatten(pairs: Any) -> dict[str, str]:
|
|
15
|
+
out: dict[str, str] = {}
|
|
16
|
+
for rdn in pairs or ():
|
|
17
|
+
for key, value in rdn:
|
|
18
|
+
out[key] = value
|
|
19
|
+
return out
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SslModule(ScanModule):
|
|
23
|
+
name = "ssl"
|
|
24
|
+
label = "SSL"
|
|
25
|
+
|
|
26
|
+
async def run(self, ctx: ScanContext) -> dict[str, Any]:
|
|
27
|
+
cert = ctx.tls_cert
|
|
28
|
+
if not cert:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
subject = _flatten(cert.get("subject"))
|
|
32
|
+
issuer = _flatten(cert.get("issuer"))
|
|
33
|
+
sans = [v for k, v in cert.get("subjectAltName", ()) if k == "DNS"]
|
|
34
|
+
|
|
35
|
+
result: dict[str, Any] = {
|
|
36
|
+
"subject_cn": subject.get("commonName"),
|
|
37
|
+
"issuer_org": issuer.get("organizationName"),
|
|
38
|
+
"issuer_cn": issuer.get("commonName"),
|
|
39
|
+
"valid_from": cert.get("notBefore"),
|
|
40
|
+
"valid_until": cert.get("notAfter"),
|
|
41
|
+
"san_count": len(sans),
|
|
42
|
+
"san": sans[:20],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
not_after = cert.get("notAfter")
|
|
46
|
+
if not_after:
|
|
47
|
+
try:
|
|
48
|
+
expires = datetime.strptime(not_after, _CERT_DATE).replace(tzinfo=timezone.utc)
|
|
49
|
+
days = (expires - datetime.now(timezone.utc)).days
|
|
50
|
+
result["days_until_expiry"] = days
|
|
51
|
+
result["expired"] = days < 0
|
|
52
|
+
# A successful handshake in prefetch means the chain validated
|
|
53
|
+
# against the system trust store.
|
|
54
|
+
result["trusted"] = not result["expired"]
|
|
55
|
+
except ValueError:
|
|
56
|
+
pass
|
|
57
|
+
return result
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Native subdomain discovery — no external service (replaces crt.sh).
|
|
2
|
+
|
|
3
|
+
Two native sources:
|
|
4
|
+
1. Subject Alternative Names on the live TLS certificate (grabbed once in
|
|
5
|
+
prefetch via ``ssl``/``socket``) — real hostnames the cert is valid for.
|
|
6
|
+
2. Resolving a curated list of common subdomains via ``socket`` and keeping the
|
|
7
|
+
ones that exist.
|
|
8
|
+
|
|
9
|
+
Less exhaustive than a Certificate Transparency search, but instant and
|
|
10
|
+
dependency-free. Swap in a CT/API source later if broader coverage is needed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import socket
|
|
17
|
+
|
|
18
|
+
from ..core.module import ScanModule
|
|
19
|
+
from ..core.context import ScanContext
|
|
20
|
+
|
|
21
|
+
COMMON_SUBDOMAINS = (
|
|
22
|
+
"www", "mail", "webmail", "smtp", "imap", "pop", "ftp", "cpanel", "webdisk",
|
|
23
|
+
"ns1", "ns2", "mx", "api", "dev", "staging", "blog", "shop", "admin",
|
|
24
|
+
"portal", "vpn", "cdn", "m",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SubdomainsModule(ScanModule):
|
|
29
|
+
name = "subdomains"
|
|
30
|
+
label = "Subdomains"
|
|
31
|
+
|
|
32
|
+
async def run(self, ctx: ScanContext) -> list[str]:
|
|
33
|
+
found: set[str] = set()
|
|
34
|
+
|
|
35
|
+
# 1. certificate SANs (from the prefetch TLS handshake)
|
|
36
|
+
if ctx.tls_cert:
|
|
37
|
+
for entry_type, value in ctx.tls_cert.get("subjectAltName", ()):
|
|
38
|
+
if entry_type != "DNS":
|
|
39
|
+
continue
|
|
40
|
+
host = value.lstrip("*.").lower()
|
|
41
|
+
if host == ctx.domain or host.endswith("." + ctx.domain):
|
|
42
|
+
found.add(host)
|
|
43
|
+
|
|
44
|
+
# 2. resolve common subdomains concurrently
|
|
45
|
+
async def probe(sub: str) -> str | None:
|
|
46
|
+
host = f"{sub}.{ctx.domain}"
|
|
47
|
+
try:
|
|
48
|
+
await asyncio.to_thread(socket.gethostbyname, host)
|
|
49
|
+
return host
|
|
50
|
+
except OSError:
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
resolved = await asyncio.gather(*(probe(s) for s in COMMON_SUBDOMAINS))
|
|
54
|
+
found.update(host for host in resolved if host)
|
|
55
|
+
found.discard(ctx.domain)
|
|
56
|
+
return sorted(found)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Technology detection via Wappalyzer.
|
|
2
|
+
|
|
3
|
+
Uses ``analyze(..., scan_type="fast")`` — a pure-HTTP path (no headless browser) —
|
|
4
|
+
which returns per-technology version, confidence, categories and groups. Rendered as
|
|
5
|
+
a multi-column :class:`Grid`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
|
|
12
|
+
from wappalyzer import analyze
|
|
13
|
+
|
|
14
|
+
from ..core.module import ScanModule
|
|
15
|
+
from ..core.context import ScanContext
|
|
16
|
+
from ..core.models import Grid
|
|
17
|
+
|
|
18
|
+
COLUMNS = ["Name", "Category", "Confidence", "Groups", "Version"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TechModule(ScanModule):
|
|
22
|
+
name = "tech"
|
|
23
|
+
label = "Tech"
|
|
24
|
+
|
|
25
|
+
async def run(self, ctx: ScanContext) -> Grid:
|
|
26
|
+
def detect() -> Grid:
|
|
27
|
+
results = analyze(url=ctx.url, scan_type="fast", timeout=30)
|
|
28
|
+
techs = results.get(ctx.url) or next(iter(results.values()), {})
|
|
29
|
+
rows = [
|
|
30
|
+
[
|
|
31
|
+
name,
|
|
32
|
+
", ".join(info.get("categories") or []) or "-",
|
|
33
|
+
f"{info.get('confidence', 0)}%",
|
|
34
|
+
", ".join(info.get("groups") or []) or "-",
|
|
35
|
+
info.get("version") or "-",
|
|
36
|
+
]
|
|
37
|
+
for name, info in techs.items()
|
|
38
|
+
]
|
|
39
|
+
# highest-confidence first, then alphabetical
|
|
40
|
+
rows.sort(key=lambda r: (-int(r[2].rstrip("%") or 0), r[0].lower()))
|
|
41
|
+
return Grid(COLUMNS, rows)
|
|
42
|
+
|
|
43
|
+
return await asyncio.to_thread(detect)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""WHOIS module — async subprocess, no temp files, rich normalised key set.
|
|
2
|
+
|
|
3
|
+
Shells out to the system ``whois`` (the same output you'd see in a terminal) and
|
|
4
|
+
parses it robustly:
|
|
5
|
+
|
|
6
|
+
- If the output contains a referral marker (``# whois.<server>``), only the last
|
|
7
|
+
section is parsed — this drops the IANA/registry TLD block (which for ccTLDs
|
|
8
|
+
like .au otherwise pollutes the domain fields with the *TLD's* dates/nameservers).
|
|
9
|
+
- Fields are matched **most-specific prefix first** across all lines, so e.g.
|
|
10
|
+
``Domain Name:`` wins over a stray ``domain:``.
|
|
11
|
+
- Covers gTLD *and* ccTLD label variants incl. per-contact names (Registrant /
|
|
12
|
+
Admin / Tech Contact Name).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import re
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from ..core.module import ScanModule
|
|
22
|
+
from ..core.context import ScanContext
|
|
23
|
+
|
|
24
|
+
# canonical field -> candidate label prefixes (lowercased), most-specific first
|
|
25
|
+
_FIELDS: dict[str, tuple[str, ...]] = {
|
|
26
|
+
"domain": ("domain name:", "domain:"),
|
|
27
|
+
"registry_domain_id": ("registry domain id:",),
|
|
28
|
+
"registrar": ("registrar name:", "registrar:", "sponsoring registrar:"),
|
|
29
|
+
"registrar_url": ("registrar url:", "registrar web:", "referral url:"),
|
|
30
|
+
"registrar_whois": ("registrar whois server:", "whois server:"),
|
|
31
|
+
"registrar_iana_id": ("registrar iana id:",),
|
|
32
|
+
"registrar_abuse_email": ("registrar abuse contact email:",),
|
|
33
|
+
"registrar_abuse_phone": ("registrar abuse contact phone:",),
|
|
34
|
+
"reseller": ("reseller name:", "reseller:"),
|
|
35
|
+
"creation_date": ("creation date:", "created on:", "registered on:", "domain registration date:", "created:", "registered:"),
|
|
36
|
+
"updated_date": ("updated date:", "last modified:", "last updated:", "modified:", "changed:"),
|
|
37
|
+
"expiry_date": ("registry expiry date:", "registrar registration expiration date:", "expiry date:", "expiration date:", "expires:", "expire:", "paid-till:"),
|
|
38
|
+
"registrant_name": ("registrant contact name:", "registrant name:"),
|
|
39
|
+
"registrant_org": ("registrant organization:", "registrant organisation:", "registrant:", "organization:", "org:"),
|
|
40
|
+
"registrant_id": ("registrant contact id:", "registrant id:"),
|
|
41
|
+
"registrant_email": ("registrant contact email:", "registrant email:"),
|
|
42
|
+
"registrant_phone": ("registrant contact phone:", "registrant phone:"),
|
|
43
|
+
"registrant_country": ("registrant country:", "country:"),
|
|
44
|
+
"admin_name": ("admin contact name:", "admin name:", "administrative contact:"),
|
|
45
|
+
"admin_email": ("admin contact email:", "admin email:"),
|
|
46
|
+
"tech_name": ("tech contact name:", "tech name:", "technical contact:"),
|
|
47
|
+
"tech_email": ("tech contact email:", "tech email:"),
|
|
48
|
+
"eligibility": ("eligibility type:",),
|
|
49
|
+
"status_reason": ("status reason:",),
|
|
50
|
+
"dnssec": ("dnssec:",),
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _first(lines: list[tuple[str, str]], prefixes: tuple[str, ...]) -> str | None:
|
|
55
|
+
for pfx in prefixes: # prefix-priority: most specific wins regardless of order
|
|
56
|
+
for low, raw in lines:
|
|
57
|
+
if low.startswith(pfx):
|
|
58
|
+
value = raw[len(pfx):].strip()
|
|
59
|
+
if value:
|
|
60
|
+
return value
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _multi(lines: list[tuple[str, str]], *prefixes: str) -> list[str]:
|
|
65
|
+
out: list[str] = []
|
|
66
|
+
for low, raw in lines:
|
|
67
|
+
if low.startswith(prefixes):
|
|
68
|
+
out.append(raw.split(":", 1)[1].strip())
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class WhoisModule(ScanModule):
|
|
73
|
+
name = "whois"
|
|
74
|
+
label = "Whois"
|
|
75
|
+
|
|
76
|
+
async def run(self, ctx: ScanContext) -> dict[str, Any]:
|
|
77
|
+
proc = await asyncio.create_subprocess_exec(
|
|
78
|
+
"whois",
|
|
79
|
+
ctx.domain,
|
|
80
|
+
stdout=asyncio.subprocess.PIPE,
|
|
81
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
82
|
+
)
|
|
83
|
+
stdout, _ = await proc.communicate()
|
|
84
|
+
text = stdout.decode("utf-8", errors="replace")
|
|
85
|
+
|
|
86
|
+
raw = text.splitlines()
|
|
87
|
+
# keep only the authoritative section (after the last referral marker)
|
|
88
|
+
markers = [i for i, ln in enumerate(raw) if ln.lstrip().lower().startswith("# whois.")]
|
|
89
|
+
if markers:
|
|
90
|
+
raw = raw[markers[-1] + 1:]
|
|
91
|
+
|
|
92
|
+
lines = [
|
|
93
|
+
(ln.strip().lower(), ln.strip())
|
|
94
|
+
for ln in raw
|
|
95
|
+
if ":" in ln and not ln.lstrip().startswith(("%", "#", ">>>"))
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
result: dict[str, Any] = {}
|
|
99
|
+
for field, prefixes in _FIELDS.items():
|
|
100
|
+
value = _first(lines, prefixes)
|
|
101
|
+
if value:
|
|
102
|
+
result[field] = value
|
|
103
|
+
|
|
104
|
+
ns = _multi(lines, "name server:", "nserver:", "nameserver:")
|
|
105
|
+
if ns:
|
|
106
|
+
result["name_servers"] = sorted({n.split()[0].lower() for n in ns if n})
|
|
107
|
+
|
|
108
|
+
status = _multi(lines, "domain status:", "status:")
|
|
109
|
+
if status:
|
|
110
|
+
result["status"] = sorted({s for s in status if s})
|
|
111
|
+
|
|
112
|
+
if not result and re.search(r"no match|not found|no entries found", text, re.IGNORECASE):
|
|
113
|
+
result["note"] = "no WHOIS match / not found"
|
|
114
|
+
return result
|
webscanner/net/http.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Blocking HTTP / DNS / TLS primitives.
|
|
2
|
+
|
|
3
|
+
Kept synchronous on purpose: the async core calls these via ``asyncio.to_thread``.
|
|
4
|
+
Centralising them here gives every module one user-agent, one timeout policy and
|
|
5
|
+
one place to add retries/proxying later.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import socket
|
|
11
|
+
import ssl
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
USER_AGENT = "web-scanner/2.0 (+https://github.com/iamramizk/web-scanner)"
|
|
18
|
+
TIMEOUT = 12
|
|
19
|
+
DEFAULT_HEADERS = {"User-Agent": USER_AGENT}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def fetch(url: str) -> dict[str, Any]:
|
|
23
|
+
"""GET a URL, following redirects; return status/headers/body/timing/final-url."""
|
|
24
|
+
start = time.perf_counter()
|
|
25
|
+
resp = requests.get(url, headers=DEFAULT_HEADERS, timeout=TIMEOUT, allow_redirects=True)
|
|
26
|
+
elapsed = (time.perf_counter() - start) * 1000
|
|
27
|
+
return {
|
|
28
|
+
"status_code": resp.status_code,
|
|
29
|
+
"headers": dict(resp.headers),
|
|
30
|
+
"html": resp.text,
|
|
31
|
+
"elapsed_ms": elapsed,
|
|
32
|
+
"final_url": resp.url,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_ip(domain: str) -> str | None:
|
|
37
|
+
"""Resolve a host to its first IPv4 address."""
|
|
38
|
+
try:
|
|
39
|
+
return socket.gethostbyname(domain)
|
|
40
|
+
except OSError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_geo(ip: str) -> dict[str, Any]:
|
|
45
|
+
"""Geolocate an IP via ip-api.com (free, no key)."""
|
|
46
|
+
resp = requests.get(f"http://ip-api.com/json/{ip}", headers=DEFAULT_HEADERS, timeout=TIMEOUT)
|
|
47
|
+
return resp.json()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_tls_cert(host: str, port: int = 443) -> dict[str, Any] | None:
|
|
51
|
+
"""Complete a TLS handshake and return the peer certificate dict (or None)."""
|
|
52
|
+
ctx = ssl.create_default_context()
|
|
53
|
+
with socket.create_connection((host, port), timeout=TIMEOUT) as sock:
|
|
54
|
+
with ctx.wrap_socket(sock, server_hostname=host) as tls:
|
|
55
|
+
return tls.getpeercert()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def doh_query(name: str, rtype: str = "A") -> dict[str, Any]:
|
|
59
|
+
"""DNS-over-HTTPS query via Cloudflare (used for blocklist checks)."""
|
|
60
|
+
resp = requests.get(
|
|
61
|
+
"https://cloudflare-dns.com/dns-query",
|
|
62
|
+
params={"name": name, "type": rtype},
|
|
63
|
+
headers={"accept": "application/dns-json", "User-Agent": USER_AGENT},
|
|
64
|
+
timeout=TIMEOUT,
|
|
65
|
+
)
|
|
66
|
+
return resp.json()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Textual UI layer (widgets, layout, world map)."""
|