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
webscanner/__main__.py
ADDED
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,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
|