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,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
@@ -0,0 +1,5 @@
1
+ """Shared network helpers (sync; called from threads by the async core)."""
2
+
3
+ from . import http
4
+
5
+ __all__ = ["http"]
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)."""