web-scanner 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
webscanner/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m webscanner``."""
2
+
3
+ from webscanner.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
webscanner/cli.py ADDED
@@ -0,0 +1,24 @@
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()
webscanner/colors.py ADDED
@@ -0,0 +1,4 @@
1
+ """Shared semantic colours used in cell markup across modules and the UI."""
2
+
3
+ GREEN = "green"
4
+ RED = "#FF6C64"
@@ -0,0 +1,15 @@
1
+ """Async scanning core: context, module contract, orchestrator, result models."""
2
+
3
+ from .context import ScanContext
4
+ from .models import ModuleResult, ModuleStatus, ScanEvent
5
+ from .module import ScanModule
6
+ from .scanner import AsyncScanner
7
+
8
+ __all__ = [
9
+ "ScanContext",
10
+ "ScanModule",
11
+ "AsyncScanner",
12
+ "ModuleResult",
13
+ "ModuleStatus",
14
+ "ScanEvent",
15
+ ]
@@ -0,0 +1,40 @@
1
+ """Shared per-scan context passed to every module.
2
+
3
+ The orchestrator's prefetch phase populates the shared network fields (one DNS
4
+ resolve, one HTTP GET, one TLS handshake, one geo lookup) so individual modules
5
+ reuse them instead of refetching.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Any
12
+
13
+ from .. import helpers
14
+
15
+
16
+ @dataclass
17
+ class ScanContext:
18
+ # inputs
19
+ domain: str # bare host, e.g. "example.com"
20
+ url: str # full url, e.g. "https://example.com"
21
+
22
+ # shared prefetch results (filled by AsyncScanner.prefetch)
23
+ ip: str | None = None
24
+ html: str | None = None
25
+ headers: dict[str, str] = field(default_factory=dict)
26
+ status_code: int | None = None
27
+ response_time_ms: float | None = None
28
+ final_url: str | None = None
29
+ tls_cert: dict[str, Any] | None = None
30
+ geo: dict[str, Any] | None = None
31
+ fetch_error: str | None = None
32
+
33
+ @classmethod
34
+ def from_target(cls, target: str) -> "ScanContext":
35
+ domain, url = helpers.normalise(target)
36
+ return cls(domain=domain, url=url)
37
+
38
+ @property
39
+ def online(self) -> bool:
40
+ return self.status_code is not None and self.fetch_error is None
@@ -0,0 +1,79 @@
1
+ """Result and event data models shared across the core and UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+
10
+ class ModuleStatus(str, Enum):
11
+ """Lifecycle state of a single scan module (also drives tab colouring)."""
12
+
13
+ PENDING = "pending" # not started
14
+ RUNNING = "running" # in flight
15
+ DONE = "done" # finished with data
16
+ EMPTY = "empty" # finished, no data found
17
+ FAILED = "failed" # raised
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class ModuleResult:
22
+ """Outcome of one module run."""
23
+
24
+ name: str
25
+ status: ModuleStatus
26
+ data: Any = None
27
+ error: str | None = None
28
+ duration_ms: float | None = None
29
+
30
+ @property
31
+ def ok(self) -> bool:
32
+ return self.status in (ModuleStatus.DONE, ModuleStatus.EMPTY)
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class ScanEvent:
37
+ """Progress signal emitted by the orchestrator; the UI subscribes to these."""
38
+
39
+ name: str # module name, or "__prefetch__" for the shared fetch phase
40
+ status: ModuleStatus
41
+ result: ModuleResult | None = None
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class Section:
46
+ """One titled sub-table within a multi-table module result."""
47
+
48
+ title: str
49
+ data: Any
50
+ headers: tuple[str, str] | None = None
51
+ #: fixed column proportions (col1, col2), e.g. (3, 2) for 60/40; None = content-fit
52
+ ratio: tuple[int, int] | None = None
53
+ #: blank line between rows
54
+ spaced: bool = False
55
+
56
+
57
+ class Sections(list):
58
+ """A module result rendered as several stacked, titled sub-tables.
59
+
60
+ A ``list`` of :class:`Section`. Used by modules (e.g. Security) that show more
61
+ than one table under a single tab.
62
+ """
63
+
64
+
65
+ class Grid(list):
66
+ """A multi-column table result: rows (list of value-lists) + column headers.
67
+
68
+ A ``list`` of rows, so an empty ``Grid`` reports EMPTY via ``_is_empty``. The
69
+ first column is treated as each row's primary name.
70
+ """
71
+
72
+ def __init__(self, columns: list[str], rows: Any) -> None:
73
+ super().__init__(rows)
74
+ self.columns = list(columns)
75
+
76
+ @property
77
+ def names(self) -> list[str]:
78
+ """First-column values (e.g. tech names for the Server-panel summary)."""
79
+ return [row[0] for row in self]
@@ -0,0 +1,27 @@
1
+ """The scan-module contract.
2
+
3
+ Every module is a small, independently replaceable unit. It reads what it needs
4
+ from the shared ``ScanContext``, does its work, and returns structured data.
5
+ Modules must never manage their own status/timing/error handling — the
6
+ orchestrator wraps each ``run`` call and isolates failures so one broken module
7
+ cannot abort the whole scan.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from abc import ABC, abstractmethod
13
+ from typing import Any
14
+
15
+ from .context import ScanContext
16
+
17
+
18
+ class ScanModule(ABC):
19
+ #: one-word identifier; also the tab id (e.g. "dns")
20
+ name: str = ""
21
+ #: human label shown in the tab bar (e.g. "DNS")
22
+ label: str = ""
23
+
24
+ @abstractmethod
25
+ async def run(self, ctx: ScanContext) -> Any:
26
+ """Do the scan and return JSON-serialisable data (or raise)."""
27
+ raise NotImplementedError
@@ -0,0 +1,103 @@
1
+ """AsyncScanner — concurrent orchestrator.
2
+
3
+ Flow:
4
+ 1. prefetch() — resolve IP, HTTP GET, TLS handshake and geo lookup, all
5
+ concurrently, populating the shared ScanContext.
6
+ 2. run() — run every module concurrently, each failure-isolated, emitting
7
+ ScanEvents so the UI can animate progress and colour tabs live.
8
+
9
+ Blocking libraries (pydig, requests, wappalyzer, system whois) are offloaded to
10
+ threads via ``asyncio.to_thread`` so the event loop never stalls.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import time
17
+ from collections.abc import Callable, Iterable
18
+
19
+ from ..net import http
20
+ from .context import ScanContext
21
+ from .models import ModuleResult, ModuleStatus, ScanEvent
22
+ from .module import ScanModule
23
+
24
+ EventCallback = Callable[[ScanEvent], None]
25
+
26
+ PREFETCH = "__prefetch__"
27
+
28
+
29
+ def _is_empty(data: object) -> bool:
30
+ if data is None:
31
+ return True
32
+ if isinstance(data, (dict, list, str, tuple, set)):
33
+ return len(data) == 0
34
+ return False
35
+
36
+
37
+ class AsyncScanner:
38
+ def __init__(
39
+ self,
40
+ ctx: ScanContext,
41
+ modules: Iterable[ScanModule],
42
+ on_event: EventCallback | None = None,
43
+ ) -> None:
44
+ self.ctx = ctx
45
+ self.modules = list(modules)
46
+ self.on_event = on_event
47
+ self.results: dict[str, ModuleResult] = {}
48
+
49
+ def _emit(self, name: str, status: ModuleStatus, result: ModuleResult | None = None) -> None:
50
+ if self.on_event is not None:
51
+ self.on_event(ScanEvent(name=name, status=status, result=result))
52
+
53
+ async def prefetch(self) -> None:
54
+ """Populate the shared context (IP first, then fetch/geo/tls concurrently)."""
55
+ self.ctx.ip = await asyncio.to_thread(http.resolve_ip, self.ctx.domain)
56
+
57
+ async def _fetch() -> None:
58
+ try:
59
+ r = await asyncio.to_thread(http.fetch, self.ctx.url)
60
+ self.ctx.status_code = r["status_code"]
61
+ self.ctx.headers = r["headers"]
62
+ self.ctx.html = r["html"]
63
+ self.ctx.response_time_ms = r["elapsed_ms"]
64
+ self.ctx.final_url = r["final_url"]
65
+ except Exception as exc: # noqa: BLE001 - surfaced, not raised
66
+ self.ctx.fetch_error = repr(exc)
67
+
68
+ async def _geo() -> None:
69
+ if self.ctx.ip:
70
+ try:
71
+ self.ctx.geo = await asyncio.to_thread(http.get_geo, self.ctx.ip)
72
+ except Exception: # noqa: BLE001
73
+ pass
74
+
75
+ async def _tls() -> None:
76
+ try:
77
+ self.ctx.tls_cert = await asyncio.to_thread(http.get_tls_cert, self.ctx.domain)
78
+ except Exception: # noqa: BLE001
79
+ pass
80
+
81
+ await asyncio.gather(_fetch(), _geo(), _tls())
82
+
83
+ async def _run_module(self, module: ScanModule) -> ModuleResult:
84
+ self._emit(module.name, ModuleStatus.RUNNING)
85
+ start = time.perf_counter()
86
+ try:
87
+ data = await module.run(self.ctx)
88
+ duration = (time.perf_counter() - start) * 1000
89
+ status = ModuleStatus.EMPTY if _is_empty(data) else ModuleStatus.DONE
90
+ result = ModuleResult(module.name, status, data=data, duration_ms=duration)
91
+ except Exception as exc: # noqa: BLE001 - isolate module failures
92
+ duration = (time.perf_counter() - start) * 1000
93
+ result = ModuleResult(module.name, ModuleStatus.FAILED, error=repr(exc), duration_ms=duration)
94
+ self.results[module.name] = result
95
+ self._emit(module.name, result.status, result)
96
+ return result
97
+
98
+ async def run(self) -> dict[str, ModuleResult]:
99
+ self._emit(PREFETCH, ModuleStatus.RUNNING)
100
+ await self.prefetch()
101
+ self._emit(PREFETCH, ModuleStatus.DONE)
102
+ await asyncio.gather(*(self._run_module(m) for m in self.modules))
103
+ return self.results
webscanner/helpers.py ADDED
@@ -0,0 +1,41 @@
1
+ """URL validation and normalisation helpers (ported/modernised from v1)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from urllib.parse import urlparse
7
+
8
+ _URL_RE = re.compile(
9
+ r"^(?:http|ftp)s?://" # scheme
10
+ r"|^" # or no scheme
11
+ r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+" # domain labels
12
+ r"(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|" # tld
13
+ r"localhost|"
14
+ r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|" # ipv4
15
+ r"\[?[A-F0-9]*:[A-F0-9:]+\]?)" # ipv6
16
+ r"(?::\d+)?" # port
17
+ r"(?:/?|[/?]\S+)?$", # path
18
+ re.IGNORECASE,
19
+ )
20
+
21
+
22
+ def is_valid_url(url: str) -> bool:
23
+ """True if the string looks like a URL/host we can scan."""
24
+ return re.match(_URL_RE, url) is not None
25
+
26
+
27
+ def to_domain(url: str) -> str:
28
+ """Reduce any URL/host to its bare registrable host (drops scheme, path, www.)."""
29
+ if not urlparse(url).scheme:
30
+ url = "https://" + url
31
+ domain = urlparse(url).netloc or urlparse(url).path
32
+ domain = domain.split("/")[0]
33
+ if domain.startswith("www."):
34
+ domain = domain[4:]
35
+ return domain.lower()
36
+
37
+
38
+ def normalise(target: str) -> tuple[str, str]:
39
+ """Return (bare_domain, full_https_url) for a user-supplied target."""
40
+ domain = to_domain(target.strip())
41
+ return domain, f"https://{domain}"
@@ -0,0 +1,38 @@
1
+ """Scan module registry.
2
+
3
+ ``all_modules()`` order defines the tab order in the UI:
4
+ DNS · Whois · Subdomains · SSL · Security · Headers · Tech · SEO · Links
5
+ (Email auth — DMARC/DKIM — is folded into DNS. Security = ports + HTTP headers + blocklists.
6
+ SEO = schema + content + keywords. Links = internal + external page links.)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ..core.module import ScanModule
12
+ from .dns import DnsModule
13
+ from .headers import HeadersModule
14
+ from .links import LinksModule
15
+ from .security import SecurityModule
16
+ from .seo import SeoModule
17
+ from .ssl import SslModule
18
+ from .subdomains import SubdomainsModule
19
+ from .tech import TechModule
20
+ from .whois import WhoisModule
21
+
22
+
23
+ def all_modules() -> list[ScanModule]:
24
+ """Fresh instances in tab order."""
25
+ return [
26
+ DnsModule(),
27
+ WhoisModule(),
28
+ SubdomainsModule(),
29
+ SslModule(),
30
+ SecurityModule(),
31
+ HeadersModule(),
32
+ TechModule(),
33
+ SeoModule(),
34
+ LinksModule(),
35
+ ]
36
+
37
+
38
+ __all__ = ["all_modules"]
@@ -0,0 +1,96 @@
1
+ """DNS records module (also folds in email auth: DMARC + DKIM discovery).
2
+
3
+ SPF and MX already surface as normal TXT/MX records, so we don't duplicate them.
4
+ DMARC lives at ``_dmarc.<domain>`` and DKIM keys at ``<selector>._domainkey.<domain>``,
5
+ so those aren't in the default record set — we look them up and append rows only
6
+ when found (no "present: yes/no" noise).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from typing import Any
13
+
14
+ import pydig
15
+
16
+ from ..core.module import ScanModule
17
+ from ..core.context import ScanContext
18
+
19
+ RECORD_TYPES = ("A", "AAAA", "NS", "CNAME", "SOA", "MX", "TXT", "CAA", "DS", "DNSKEY")
20
+
21
+ # Known DKIM selectors across the major providers/ESPs. DKIM can't be enumerated
22
+ # from DNS alone, so we probe this list of well-known selectors.
23
+ DKIM_SELECTORS = (
24
+ # generic
25
+ "default", "dkim", "mail", "selector", "s", "s1", "s2", "key1", "dk",
26
+ # Microsoft 365 / Outlook
27
+ "selector1", "selector2",
28
+ # Google Workspace
29
+ "google",
30
+ # Amazon SES
31
+ "amazonses",
32
+ # Zoho
33
+ "zoho", "zmail",
34
+ # ProtonMail
35
+ "protonmail", "protonmail2", "protonmail3",
36
+ # Fastmail
37
+ "fm1", "fm2", "fm3",
38
+ # Mailchimp / Mandrill
39
+ "k1", "k2", "k3", "mandrill",
40
+ # SendGrid
41
+ "smtpapi",
42
+ # Postmark
43
+ "pm",
44
+ # Mailgun / generic smtp
45
+ "smtp", "mg", "mailo",
46
+ # Campaign Monitor
47
+ "cm",
48
+ # Apple iCloud
49
+ "sig1",
50
+ # HubSpot
51
+ "hs1", "hs2",
52
+ # misc common
53
+ "mxvault", "everlytickey1", "everlytickey2", "titan1", "titan2",
54
+ "turbo-smtp", "mailjet", "sendinblue", "klaviyo",
55
+ )
56
+
57
+
58
+ def _is_dkim(records: list[str]) -> bool:
59
+ return any(("dkim1" in r.lower() or "p=" in r.lower()) for r in records)
60
+
61
+
62
+ class DnsModule(ScanModule):
63
+ name = "dns"
64
+ label = "DNS"
65
+
66
+ async def run(self, ctx: ScanContext) -> dict[str, Any]:
67
+ domain = ctx.domain
68
+
69
+ def records() -> dict[str, list[str]]:
70
+ out: dict[str, list[str]] = {}
71
+ for rtype in RECORD_TYPES:
72
+ res = pydig.query(domain, rtype)
73
+ if res:
74
+ out[rtype] = res
75
+ return out
76
+
77
+ async def dmarc() -> list[str]:
78
+ txt = await asyncio.to_thread(pydig.query, f"_dmarc.{domain}", "TXT")
79
+ return [t for t in txt if "dmarc1" in t.lower()]
80
+
81
+ async def dkim_selector(sel: str) -> str | None:
82
+ txt = await asyncio.to_thread(pydig.query, f"{sel}._domainkey.{domain}", "TXT")
83
+ return sel if txt and _is_dkim(txt) else None
84
+
85
+ out, dmarc_records, dkim_hits = await asyncio.gather(
86
+ asyncio.to_thread(records),
87
+ dmarc(),
88
+ asyncio.gather(*(dkim_selector(s) for s in DKIM_SELECTORS)),
89
+ )
90
+
91
+ if dmarc_records:
92
+ out["DMARC"] = dmarc_records
93
+ found = [s for s in dkim_hits if s]
94
+ if found:
95
+ out["DKIM"] = found
96
+ return out
@@ -0,0 +1,16 @@
1
+ """HTTP response headers (reuses the shared prefetch response)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..core.module import ScanModule
8
+ from ..core.context import ScanContext
9
+
10
+
11
+ class HeadersModule(ScanModule):
12
+ name = "headers"
13
+ label = "Headers"
14
+
15
+ async def run(self, ctx: ScanContext) -> dict[str, Any]:
16
+ return dict(ctx.headers)
@@ -0,0 +1,69 @@
1
+ """Page links module — internal vs external links found on the page.
2
+
3
+ Two tables (link text → href URL): Internal (same registrable domain, incl.
4
+ relative links and subdomains) and External (everything else). Uses the HTML
5
+ fetched once during prefetch.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from urllib.parse import urljoin, urlparse
12
+
13
+ from bs4 import BeautifulSoup
14
+
15
+ from ..core.module import ScanModule
16
+ from ..core.context import ScanContext
17
+ from ..core.models import Section, Sections
18
+
19
+ _SKIP_PREFIXES = ("#", "javascript:", "mailto:", "tel:", "data:")
20
+
21
+
22
+ class LinksModule(ScanModule):
23
+ name = "links"
24
+ label = "Links"
25
+
26
+ async def run(self, ctx: ScanContext) -> Sections:
27
+ if not ctx.html:
28
+ internal: list[tuple[str, str]] = []
29
+ external: list[tuple[str, str]] = []
30
+ else:
31
+ internal, external = await asyncio.to_thread(self._parse, ctx)
32
+
33
+ return Sections([
34
+ Section("Internal", internal or [("—", "no internal links found")], ("Link text", "URL")),
35
+ Section("External", external or [("—", "no external links found")], ("Link text", "URL")),
36
+ ])
37
+
38
+ @staticmethod
39
+ def _parse(ctx: ScanContext) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
40
+ soup = BeautifulSoup(ctx.html, "html.parser")
41
+ base = ctx.final_url or ctx.url
42
+ internal: list[tuple[str, str]] = []
43
+ external: list[tuple[str, str]] = []
44
+ seen_i: set[str] = set()
45
+ seen_e: set[str] = set()
46
+
47
+ for tag in soup.find_all("a", href=True):
48
+ href = tag["href"].strip()
49
+ if not href or href.startswith(_SKIP_PREFIXES):
50
+ continue
51
+ url = urljoin(base, href)
52
+ parsed = urlparse(url)
53
+ if parsed.scheme not in ("http", "https"):
54
+ continue
55
+
56
+ text = " ".join(tag.get_text(strip=True).split()) or "-"
57
+ host = parsed.netloc.lower()
58
+ if host.startswith("www."):
59
+ host = host[4:]
60
+
61
+ if host == ctx.domain or host.endswith("." + ctx.domain):
62
+ if url not in seen_i:
63
+ seen_i.add(url)
64
+ internal.append((text, url))
65
+ elif url not in seen_e:
66
+ seen_e.add(url)
67
+ external.append((text, url))
68
+
69
+ return internal, external