From b055344e499fbfe2dfb48c6ef83f0730d0b65018 Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 18:56:52 +0330 Subject: [PATCH 01/46] fix: add secure report_store implementation --- modules/report_store.py | 99 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 modules/report_store.py diff --git a/modules/report_store.py b/modules/report_store.py new file mode 100644 index 0000000..2e50465 --- /dev/null +++ b/modules/report_store.py @@ -0,0 +1,99 @@ +"""Atomic and consistent report storage helpers for SpectraScan.""" +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any, Optional + +REPORT_EXTENSIONS = {".json", ".html", ".csv", ".txt"} + + +def get_report_dir() -> Path: + """Return and create the configured report directory.""" + configured = os.environ.get("SPECTRASCAN_HOME") + directory = Path(configured).expanduser() if configured else ( + Path.home() / ".local" / "share" / "SpectraScan" + ) + directory.mkdir(parents=True, exist_ok=True) + return directory.resolve() + + +def atomic_write_text(path: os.PathLike[str] | str, content: str, encoding: str = "utf-8") -> str: + """Write text atomically in the destination directory.""" + destination = Path(path).expanduser().absolute() + destination.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp( + prefix=f".{destination.name}.", suffix=".tmp", dir=str(destination.parent) + ) + try: + with os.fdopen(fd, "w", encoding=encoding, newline="") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, destination) + except BaseException: + try: + os.unlink(temporary) + except OSError: + pass + raise + return str(destination) + + +def atomic_write_json(path: os.PathLike[str] | str, data: Any) -> str: + return atomic_write_text( + path, + json.dumps(data, indent=2, ensure_ascii=False, default=str) + "\n", + ) + + +def safe_csv_cell(value: Any) -> str: + """Prevent spreadsheet formula execution from untrusted CSV values.""" + text = "" if value is None else str(value) + probe = text.lstrip(" \t\r\n") + if probe.startswith(("=", "+", "-", "@")): + return "'" + text + return text + + +class ReportStore: + """Shared interface for report paths and history operations.""" + + def __init__(self, directory: Optional[os.PathLike[str] | str] = None) -> None: + self.directory = Path(directory).expanduser().resolve() if directory else get_report_dir() + self.directory.mkdir(parents=True, exist_ok=True) + + def save_text(self, filename: str, content: str) -> str: + return atomic_write_text(self.directory / filename, content) + + def save_json(self, filename: str, data: Any) -> str: + return atomic_write_json(self.directory / filename, data) + + def list_reports(self) -> list[str]: + return sorted( + ( + item.name for item in self.directory.iterdir() + if not item.is_symlink() and item.is_file() + and item.suffix.lower() in REPORT_EXTENSIONS + ), + key=str.casefold, + ) + + def _safe_path(self, filename: str) -> Path: + candidate = Path(filename) + if candidate.name != filename or filename in {"", ".", ".."}: + raise ValueError("Report name must be a plain filename") + path = self.directory / filename + if path.is_symlink() or not path.is_file(): + raise FileNotFoundError(filename) + if path.resolve().parent != self.directory: + raise ValueError("Report path escapes the report directory") + return path + + def read_text(self, filename: str, encoding: str = "utf-8") -> str: + return self._safe_path(filename).read_text(encoding=encoding) + + def delete(self, filename: str) -> None: + self._safe_path(filename).unlink() From f0968a90eb0ca23d6583d632aeec1d7675b12a7d Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 18:58:35 +0330 Subject: [PATCH 02/46] fix: harden async_scanner --- modules/async_scanner.py | 295 +++++++++++++++++++-------------------- 1 file changed, 143 insertions(+), 152 deletions(-) diff --git a/modules/async_scanner.py b/modules/async_scanner.py index 6135a31..0bfa9ed 100644 --- a/modules/async_scanner.py +++ b/modules/async_scanner.py @@ -1,29 +1,23 @@ -"""Asynchronous TCP/HTTP scanning engine for SpectraScan. - -This module is additive: the legacy PortScanner remains unchanged and callers -can opt into AsyncPortScanner when they want asyncio/aiohttp concurrency. -""" - +"""Bounded asynchronous TCP scanner with verified HTTP/TLS enrichment.""" from __future__ import annotations import asyncio +import ipaddress +import math import socket import time -from typing import Any, Dict, Iterable, List, Optional, Tuple +import urllib.parse +from typing import Any, Dict, Iterable, List, Optional import aiohttp - HTTP_PORTS = {80, 443, 8000, 8080, 8081, 8443, 8888} +MAX_CONCURRENCY = 512 +MAX_TIMEOUT = 120.0 class AsyncPortScanner: - """Concurrent TCP scanner with optional aiohttp HTTP enrichment. - - The result schema intentionally mirrors the useful subset of PortScanner: - target, resolved_ip, scan_type, timestamp, duration, open_ports and - vulnerabilities. - """ + """Concurrent TCP scanner with optional read-only HTTP metadata collection.""" def __init__( self, @@ -34,81 +28,98 @@ def __init__( check_http: bool = True, user_agent: str = "SpectraScan/async", ) -> None: - if timeout <= 0: - raise ValueError("timeout must be greater than 0") - if concurrency <= 0: - raise ValueError("concurrency must be greater than 0") - - self.target = target - self.ports = sorted({int(port) for port in (ports or [])}) + if not target or not str(target).strip(): + raise ValueError("target must not be empty") + if not math.isfinite(float(timeout)) or not 0 < float(timeout) <= MAX_TIMEOUT: + raise ValueError(f"timeout must be between 0 and {MAX_TIMEOUT:g} seconds") + if isinstance(concurrency, bool) or not 1 <= int(concurrency) <= MAX_CONCURRENCY: + raise ValueError(f"concurrency must be between 1 and {MAX_CONCURRENCY}") + try: + normalized_ports = sorted({int(port) for port in (ports or [])}) + except (TypeError, ValueError, OverflowError) as exc: + raise ValueError("ports must be integers in the range 1..65535") from exc + if not normalized_ports: + raise ValueError("at least one port must be supplied") + if any(isinstance(port, bool) or port < 1 or port > 65535 for port in normalized_ports): + raise ValueError("ports must be in the range 1..65535") + self.target = str(target).strip() + self.ports = normalized_ports self.timeout = float(timeout) self.concurrency = int(concurrency) - self.check_http = check_http + self.check_http = bool(check_http) self.user_agent = user_agent self.resolved_ip: Optional[str] = None self.results: List[Dict[str, Any]] = [] self.vulnerabilities: List[Dict[str, Any]] = [] self.start_time: Optional[float] = None + def _hostname(self) -> str: + value = self.target.strip() + if "://" in value: + hostname = urllib.parse.urlsplit(value).hostname + if not hostname: + raise ValueError(f"Invalid target URL: {self.target}") + return hostname + if value.startswith("[") and "]" in value: + return value[1:value.index("]")] + try: + ipaddress.IPv6Address(value) + return value + except ValueError: + pass + if value.count(":") == 1: + host, port = value.rsplit(":", 1) + if port.isdigit() and host: + return host + return value.rstrip("/") + async def resolve(self) -> str: - """Resolve the target without blocking the event loop.""" + """Resolve IPv4 or IPv6 asynchronously.""" + hostname = self._hostname() + try: + ipaddress.ip_address(hostname) + self.resolved_ip = hostname + return hostname + except ValueError: + pass loop = asyncio.get_running_loop() - infos = await loop.getaddrinfo( - self.target, - None, - family=socket.AF_INET, - type=socket.SOCK_STREAM, - ) + try: + infos = await loop.getaddrinfo( + hostname, None, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM + ) + except socket.gaierror as exc: + raise ValueError(f"Cannot resolve target: {hostname}") from exc if not infos: - raise ValueError(f"Cannot resolve target: {self.target}") - + raise ValueError(f"Cannot resolve target: {hostname}") self.resolved_ip = infos[0][4][0] return self.resolved_ip - async def _probe_tcp( - self, - port: int, - semaphore: asyncio.Semaphore, - ) -> Dict[str, Any]: + async def _probe_tcp(self, port: int, semaphore: asyncio.Semaphore) -> Dict[str, Any]: result: Dict[str, Any] = { - "port": port, - "protocol": "tcp", - "state": "closed", - "service": self._service_name(port), - "banner": "", - "error": None, + "port": port, "protocol": "tcp", "state": "closed", + "service": self._service_name(port), "banner": "", "error": None, } - async with semaphore: writer: Optional[asyncio.StreamWriter] = None try: reader, writer = await asyncio.wait_for( - asyncio.open_connection(self.resolved_ip, port), - timeout=self.timeout, + asyncio.open_connection(self.resolved_ip, port), timeout=self.timeout ) result["state"] = "open" - - if port in HTTP_PORTS: + if port in HTTP_PORTS and port not in {443, 8443}: + host = self._hostname() request = ( - f"HEAD / HTTP/1.1\r\n" - f"Host: {self.target}\r\n" + f"HEAD / HTTP/1.1\r\nHost: {host}\r\n" f"User-Agent: {self.user_agent}\r\n" "Connection: close\r\n\r\n" ) - writer.write(request.encode("ascii", errors="ignore")) + writer.write(request.encode("ascii", errors="replace")) await writer.drain() - try: - data = await asyncio.wait_for( - reader.read(1024), - timeout=min(self.timeout, 1.5), - ) - result["banner"] = data.decode( - "utf-8", errors="replace" - ).strip()[:4096] + data = await asyncio.wait_for(reader.read(1024), timeout=min(self.timeout, 1.5)) + result["banner"] = data.decode("utf-8", errors="replace").strip()[:4096] except asyncio.TimeoutError: pass - except asyncio.TimeoutError: result["state"] = "filtered" except (ConnectionRefusedError, ConnectionResetError): @@ -118,33 +129,29 @@ async def _probe_tcp( result["state"] = "closed" else: result["state"] = "error" - result["error"] = str(exc) + result["error"] = f"{type(exc).__name__}: {exc}" finally: if writer is not None: writer.close() try: await writer.wait_closed() - except (AttributeError, OSError): + except (AttributeError, OSError, asyncio.CancelledError): pass - return result async def _http_enrich( - self, - result: Dict[str, Any], - session: aiohttp.ClientSession, + self, result: Dict[str, Any], session: aiohttp.ClientSession, semaphore: asyncio.Semaphore, ) -> Dict[str, Any]: port = int(result["port"]) scheme = "https" if port in {443, 8443} else "http" - url = f"{scheme}://{self.target}:{port}/" - + hostname = self._hostname() + formatted_host = f"[{hostname}]" if ":" in hostname and not hostname.startswith("[") else hostname + url = f"{scheme}://{formatted_host}:{port}/" async with semaphore: try: async with session.get( - url, - allow_redirects=False, - ssl=False, + url, allow_redirects=False, timeout=aiohttp.ClientTimeout(total=self.timeout), ) as response: result["http"] = { @@ -152,121 +159,105 @@ async def _http_enrich( "server": response.headers.get("Server"), "content_type": response.headers.get("Content-Type"), "location": response.headers.get("Location"), + "tls_verified": scheme == "https", "security_headers": { name: response.headers.get(name) for name in ( - "Strict-Transport-Security", - "Content-Security-Policy", - "X-Frame-Options", - "X-Content-Type-Options", - ) - if response.headers.get(name) is not None + "Strict-Transport-Security", "Content-Security-Policy", + "X-Frame-Options", "X-Content-Type-Options", + "Referrer-Policy", "Permissions-Policy", + ) if response.headers.get(name) is not None }, } except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc: - result["http_error"] = str(exc) - + result["http_error"] = f"{type(exc).__name__}: {exc}" return result async def scan_async(self) -> List[Dict[str, Any]]: - """Run the asynchronous scan and return open-port results.""" + """Run a bounded scan; task count is bounded by configured concurrency.""" self.start_time = time.monotonic() + self.results = [] + self.vulnerabilities = [] await self.resolve() - semaphore = asyncio.Semaphore(self.concurrency) - tasks = [ - asyncio.create_task(self._probe_tcp(port, semaphore)) - for port in self.ports - ] - scanned = await asyncio.gather(*tasks) + work_queue: asyncio.Queue[Optional[int]] = asyncio.Queue(maxsize=max(1, self.concurrency * 2)) + worker_count = min(self.concurrency, len(self.ports)) + results: List[Dict[str, Any]] = [] - open_results = [ - result for result in scanned if result["state"] == "open" - ] - - if self.check_http and open_results: - http_results = [r for r in open_results if r["port"] in HTTP_PORTS] + async def worker() -> None: + while True: + port = await work_queue.get() + try: + if port is None: + return + result = await self._probe_tcp(port, semaphore) + if result["state"] == "open": + results.append(result) + finally: + work_queue.task_done() + + async def producer() -> None: + for port in self.ports: + await work_queue.put(port) + for _ in range(worker_count): + await work_queue.put(None) + + workers = [asyncio.create_task(worker()) for _ in range(worker_count)] + producer_task = asyncio.create_task(producer()) + try: + await producer_task + await work_queue.join() + await asyncio.gather(*workers) + except BaseException: + producer_task.cancel() + for task in workers: + task.cancel() + await asyncio.gather(producer_task, *workers, return_exceptions=True) + raise + results.sort(key=lambda item: item["port"]) + if self.check_http and results: + http_results = [item for item in results if item["port"] in HTTP_PORTS] if http_results: - connector = aiohttp.TCPConnector( - limit=self.concurrency, - ssl=False, - ) timeout = aiohttp.ClientTimeout(total=self.timeout) + # aiohttp certificate verification stays enabled by default. + connector = aiohttp.TCPConnector(limit=self.concurrency) async with aiohttp.ClientSession( - connector=connector, - timeout=timeout, + connector=connector, timeout=timeout, headers={"User-Agent": self.user_agent}, ) as session: await asyncio.gather( - *( - self._http_enrich(r, session, semaphore) - for r in http_results - ) + *(self._http_enrich(item, session, semaphore) for item in http_results) ) - - self.results = open_results + self.results = results return self.results def scan(self) -> List[Dict[str, Any]]: - """Synchronous compatibility entry point for scripts.""" + """Synchronous API; call scan_async from an existing event loop.""" return asyncio.run(self.scan_async()) def get_results(self) -> Dict[str, Any]: - duration = ( - time.monotonic() - self.start_time - if self.start_time is not None - else 0.0 - ) return { - "target": self.target, - "resolved_ip": self.resolved_ip, - "scan_type": "async-tcp", - "timestamp": time.time(), - "duration": duration, - "open_ports": self.results, - "vulnerabilities": self.vulnerabilities, + "target": self.target, "resolved_ip": self.resolved_ip, + "scan_type": "async-tcp", "timestamp": time.time(), + "duration": time.monotonic() - self.start_time if self.start_time is not None else 0.0, + "open_ports": self.results, "vulnerabilities": self.vulnerabilities, } @staticmethod def _service_name(port: int) -> str: - services = { - 21: "FTP", - 22: "SSH", - 25: "SMTP", - 53: "DNS", - 80: "HTTP", - 110: "POP3", - 143: "IMAP", - 443: "HTTPS", - 445: "SMB", - 993: "IMAPS", - 995: "POP3S", - 1433: "MSSQL", - 3306: "MySQL", - 3389: "RDP", - 5432: "PostgreSQL", - 5900: "VNC", - 6379: "Redis", - 8080: "HTTP-Alt", - 8443: "HTTPS-Alt", - 27017: "MongoDB", - } - return services.get(port, "unknown") + return { + 21: "FTP", 22: "SSH", 25: "SMTP", 53: "DNS", 80: "HTTP", + 110: "POP3", 143: "IMAP", 443: "HTTPS", 445: "SMB", + 993: "IMAPS", 995: "POP3S", 1433: "MSSQL", 3306: "MySQL", + 3389: "RDP", 5432: "PostgreSQL", 5900: "VNC", 6379: "Redis", + 8080: "HTTP-Alt", 8443: "HTTPS-Alt", 27017: "MongoDB", + }.get(port, "unknown") async def scan( - target: str, - ports: Iterable[int], - timeout: float = 1.0, - concurrency: int = 100, - check_http: bool = True, + target: str, ports: Iterable[int], timeout: float = 1.0, + concurrency: int = 100, check_http: bool = True, ) -> List[Dict[str, Any]]: - """Small functional API for callers that already run an event loop.""" - scanner = AsyncPortScanner( - target, - ports=ports, - timeout=timeout, - concurrency=concurrency, - check_http=check_http, - ) + """Functional API for callers that already run an event loop.""" + scanner = AsyncPortScanner(target, ports, timeout, concurrency, check_http) return await scanner.scan_async() From 809fc55fb5ff68787e66afe35de7c7641e9c91d9 Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 18:58:38 +0330 Subject: [PATCH 03/46] fix: harden brute_forcer --- modules/brute_forcer.py | 217 +++++++++++++++++++++++++++++----------- 1 file changed, 158 insertions(+), 59 deletions(-) diff --git a/modules/brute_forcer.py b/modules/brute_forcer.py index 6313d2c..bfe8c56 100644 --- a/modules/brute_forcer.py +++ b/modules/brute_forcer.py @@ -1,82 +1,181 @@ +"""Credential validation for explicitly authorized SSH/FTP audits. + +Never logs or retains plaintext passwords. SSH trusts known-host entries or +an explicitly configured SHA256 host-key fingerprint. +""" +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import queue import socket import threading -import queue -import logging +from collections import Counter +from pathlib import Path +from typing import Any, Dict, Optional + +logger = logging.getLogger(__name__) + + +def _fingerprint(key: Any) -> str: + digest = hashlib.sha256(key.asbytes()).digest() + return "SHA256:" + base64.b64encode(digest).decode("ascii").rstrip("=") -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') class BruteForcer: - def __init__(self, target, port, service, wordlist_path, max_threads=10): - self.target = target - self.port = port - self.service = service - self.wordlist_path = wordlist_path - self.max_threads = max_threads - self.results = [] + """Bounded worker queue with explicit result/error categories.""" + + def __init__( + self, target: str, port: int, service: str, wordlist_path: str, + max_threads: int = 10, known_hosts_path: Optional[str] = None, + expected_host_fingerprint: Optional[str] = None, + ) -> None: + self.target = str(target) + self.port = int(port) + self.service = str(service).lower() + self.wordlist_path = str(wordlist_path) + if not 1 <= self.port <= 65535: + raise ValueError("port must be in the range 1..65535") + if self.service not in {"ssh", "ftp"}: + raise ValueError("service must be 'ssh' or 'ftp'") + if isinstance(max_threads, bool) or not 1 <= int(max_threads) <= 32: + raise ValueError("max_threads must be between 1 and 32") + self.max_threads = int(max_threads) + self.known_hosts_path = known_hosts_path + self.expected_host_fingerprint = expected_host_fingerprint + self.results: list[Dict[str, Any]] = [] + self.error_counts: Counter[str] = Counter() self.lock = threading.Lock() - def load_wordlist(self): + def load_wordlist(self) -> list[str]: try: - with open(self.wordlist_path, 'r', errors='ignore') as f: - return [line.strip() for line in f if line.strip()] - except FileNotFoundError: - logging.error(f"Wordlist not found: {self.wordlist_path}") + with open(self.wordlist_path, "r", encoding="utf-8", errors="ignore") as stream: + return [line.strip() for line in stream if line.strip()] + except OSError as exc: + logger.error("Unable to read credential wordlist (%s)", type(exc).__name__) return [] - def check_ssh(self, username, password): + def _record(self, outcome: Dict[str, Any]) -> None: + category = str(outcome.get("category", "unknown_error")) + with self.lock: + if outcome.get("status") == "success": + # Whitelist output fields; never persist a password or exception text. + self.results.append({ + "status": "SUCCESS", "service": self.service, + "target": self.target, "port": self.port, + "username": str(outcome.get("username", "")), + "category": "authenticated", + }) + elif outcome.get("status") == "error": + self.error_counts[category] += 1 + + def check_ssh(self, username: str, password: str) -> Dict[str, Any]: try: import paramiko - client = paramiko.SSHClient() - client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - client.connect(self.target, port=self.port, username=username, password=password, timeout=2) - with self.lock: - self.results.append({'status': 'SUCCESS', 'user': username, 'pass': password}) - logging.info(f"[+] SSH Success: {username}:{password}") - client.close() - return True + except ImportError: + return {"status": "error", "category": "dependency_missing"} + client = paramiko.SSHClient() + try: + client.load_system_host_keys() + if self.known_hosts_path: + client.load_host_keys(str(Path(self.known_hosts_path).expanduser())) + expected = (self.expected_host_fingerprint or "").strip() + if expected: + class PinnedHostKeyPolicy(paramiko.MissingHostKeyPolicy): + def missing_host_key(policy_self, ssh_client, hostname, key): + actual = _fingerprint(key) + if not hmac.compare_digest(actual, expected): + raise paramiko.SSHException("Configured host-key fingerprint mismatch") + ssh_client.get_host_keys().add(hostname, key.get_name(), key) + client.set_missing_host_key_policy(PinnedHostKeyPolicy()) + else: + client.set_missing_host_key_policy(paramiko.RejectPolicy()) + client.connect( + self.target, port=self.port, username=username, password=password, + timeout=5, banner_timeout=5, auth_timeout=5, + allow_agent=False, look_for_keys=False, + ) + remote_key = client.get_transport().get_remote_server_key() + if expected and not hmac.compare_digest(_fingerprint(remote_key), expected): + return {"status": "error", "category": "host_key_mismatch"} + return {"status": "success", "category": "authenticated", "username": username} + except paramiko.BadHostKeyException: + return {"status": "error", "category": "host_key_mismatch"} + except paramiko.AuthenticationException: + return {"status": "failed", "category": "invalid_credentials"} + except paramiko.SSHException as exc: + message = str(exc).lower() + category = "host_key_unverified" if "known_hosts" in message or "host key" in message else "ssh_error" + return {"status": "error", "category": category} + except (socket.timeout, TimeoutError): + return {"status": "error", "category": "timeout"} + except OSError: + return {"status": "error", "category": "connection_error"} except Exception: - return False + return {"status": "error", "category": "ssh_error"} + finally: + try: + client.close() + except Exception: + pass - def check_ftp(self, username, password): + def check_ftp(self, username: str, password: str) -> Dict[str, Any]: + import ftplib + ftp = ftplib.FTP() try: - import ftplib - ftp = ftplib.FTP() - ftp.connect(self.target, self.port, timeout=2) + ftp.connect(self.target, self.port, timeout=5) ftp.login(username, password) - with self.lock: - self.results.append({'status': 'SUCCESS', 'user': username, 'pass': password}) - logging.info(f"[+] FTP Success: {username}:{password}") - ftp.quit() - return True + return {"status": "success", "category": "authenticated", "username": username} + except ftplib.error_perm: + return {"status": "failed", "category": "invalid_credentials"} + except (socket.timeout, TimeoutError): + return {"status": "error", "category": "timeout"} + except OSError: + return {"status": "error", "category": "connection_error"} except Exception: - return False + return {"status": "error", "category": "ftp_error"} + finally: + try: + ftp.quit() + except Exception: + try: + ftp.close() + except Exception: + pass - def run(self): + def run(self) -> list[Dict[str, Any]]: wordlist = self.load_wordlist() if not wordlist: - return - - tasks = queue.Queue() - threads = [] - - def worker(): - while not tasks.empty(): - username, password = tasks.get() - if self.service == 'ssh': - self.check_ssh(username, password) - elif self.service == 'ftp': - self.check_ftp(username, password) - tasks.task_done() - - for _ in range(self.max_threads): - t = threading.Thread(target=worker) - t.daemon = True - t.start() - threads.append(t) + return [] + tasks: queue.Queue[Optional[tuple[str, str]]] = queue.Queue() + for password in wordlist: + tasks.put(("admin", password)) + tasks.put(("root", password)) + worker_count = min(self.max_threads, tasks.qsize()) + for _ in range(worker_count): + tasks.put(None) - for pwd in wordlist: - tasks.put(('admin', pwd)) - tasks.put(('root', pwd)) + def worker() -> None: + while True: + item = tasks.get() + try: + if item is None: + return + username, password = item + try: + outcome = self.check_ssh(username, password) if self.service == "ssh" else self.check_ftp(username, password) + except Exception: + outcome = {"status": "error", "category": "worker_error"} + self._record(outcome) + finally: + tasks.task_done() + threads = [threading.Thread(target=worker, name=f"spectrascan-auth-{i}", daemon=True) for i in range(worker_count)] + for thread in threads: + thread.start() tasks.join() - return self.results \ No newline at end of file + for thread in threads: + thread.join() + return list(self.results) From b8dfda20cb09ed496c93dea89f7a2bbd2b682f05 Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 18:58:41 +0330 Subject: [PATCH 04/46] fix: harden vuln_scanner --- modules/vuln_scanner.py | 265 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 240 insertions(+), 25 deletions(-) diff --git a/modules/vuln_scanner.py b/modules/vuln_scanner.py index 49a26e4..5f0eee1 100644 --- a/modules/vuln_scanner.py +++ b/modules/vuln_scanner.py @@ -1,33 +1,248 @@ +"""NVD CVE lookup with cautious CPE/version correlation. + +Keyword-only matches are never presented as confirmed vulnerabilities. +The legacy VulnScanner(service_name, version) constructor remains supported. +""" +from __future__ import annotations + +import re +import time +from typing import Any, Dict, Iterable, Optional + import requests -import logging -logging.basicConfig(level=logging.WARNING) +NVD_ENDPOINT = "https://services.nvd.nist.gov/rest/json/cves/2.0" + + +def _version_key(value: str) -> Optional[tuple]: + """Compare plain numeric dotted versions; decline ambiguous formats.""" + if not value or not re.fullmatch(r"\d+(?:\.\d+){0,7}", value): + return None + try: + return tuple(int(part) for part in value.split(".")) + except ValueError: + return None + + +def _cpe_parts(criteria: str) -> tuple[Optional[str], Optional[str], Optional[str]]: + parts = (criteria or "").split(":") + if len(parts) < 6 or not parts[0].startswith("cpe"): + return None, None, None + return parts[3].lower(), parts[4].lower(), parts[5].lower() + + +def _all_cpe_matches(value: Any) -> Iterable[Dict[str, Any]]: + if isinstance(value, dict): + if "criteria" in value or "cpe23Uri" in value: + yield value + for child in value.values(): + yield from _all_cpe_matches(child) + elif isinstance(value, list): + for child in value: + yield from _all_cpe_matches(child) + + +def _version_in_range(version: str, match: Dict[str, Any]) -> Optional[bool]: + key = _version_key(version) + if key is None: + return None + criteria = match.get("criteria") or match.get("cpe23Uri") or "" + _, _, cpe_version = _cpe_parts(criteria) + if cpe_version not in {None, "", "*", "-"}: + cpe_key = _version_key(cpe_version) + if cpe_key is None: + return version.lower() == cpe_version.lower() + return key == cpe_key + bounds = ( + ("versionStartIncluding", ">="), ("versionStartExcluding", ">"), + ("versionEndIncluding", "<="), ("versionEndExcluding", "<"), + ) + for field, operation in bounds: + bound = match.get(field) + if not bound: + continue + bound_key = _version_key(str(bound)) + if bound_key is None: + return None + if operation == ">=" and key < bound_key: + return False + if operation == ">" and key <= bound_key: + return False + if operation == "<=" and key > bound_key: + return False + if operation == "<" and key >= bound_key: + return False + has_range = any(match.get(field) for field, _ in bounds) + return True if has_range or cpe_version in {"*", "-"} else None + class VulnScanner: - def __init__(self, service_name, version): - self.service = service_name.lower() - self.version = version - self.cves = [] + def __init__( + self, service_name: str, version: Optional[str] = None, + vendor: Optional[str] = None, product: Optional[str] = None, + cpe: Optional[str] = None, api_key: Optional[str] = None, + timeout: float = 8.0, max_records: int = 300, + session: Optional[requests.Session] = None, + ) -> None: + self.service = (service_name or "").strip().lower() + self.version = (version or "").strip() + self.vendor = (vendor or "").strip().lower() + self.product = (product or service_name or "").strip().lower() + self.cpe = (cpe or self._build_cpe()).strip() or None + self.api_key = api_key + self.timeout = max(1.0, min(float(timeout), 60.0)) + self.max_records = max(1, min(int(max_records), 2000)) + self.session = session or requests.Session() + self.cves: list[Dict[str, Any]] = [] + self.errors: list[Dict[str, Any]] = [] + + def _build_cpe(self) -> str: + safe = re.compile(r"^[a-z0-9_.-]+$") + if self.vendor and self.product and self.version and all( + safe.fullmatch(value) for value in (self.vendor, self.product, self.version) + ): + return f"cpe:2.3:a:{self.vendor}:{self.product}:{self.version}:*:*:*:*:*:*:*" + return "" - def check_nvd_api(self): - url = f"https://services.nvd.nist.gov/rest/json/cves/2.0?keywordSearch={self.service}&versionStartInclude={self.version}" - headers = {'Accept': 'application/json'} - try: - response = requests.get(url, headers=headers, timeout=5) - if response.status_code == 200: - data = response.json() - for item in data.get('vulnerabilities', []): - cve_id = item['cve']['id'] - severity = item['cve']['metrics']['cvssMetricV3'][0]['cvssData']['baseSeverity'] + def _params(self, start_index: int) -> Dict[str, Any]: + params: Dict[str, Any] = { + "startIndex": start_index, + "resultsPerPage": min(200, self.max_records - start_index), + } + if self.cpe: + params["cpeName"] = self.cpe + else: + params["keywordSearch"] = " ".join( + value for value in (self.vendor, self.product, self.version) if value + ) + return params + + @staticmethod + def _metrics(cve: Dict[str, Any]) -> tuple[Optional[str], Optional[float], Optional[str]]: + metrics = cve.get("metrics") or {} + for key in ("cvssMetricV40", "cvssMetricV31", "cvssMetricV30", "cvssMetricV2"): + values = metrics.get(key) + if not isinstance(values, list) or not values: + continue + entry = values[0] if isinstance(values[0], dict) else {} + cvss = entry.get("cvssData") or {} + severity = cvss.get("baseSeverity") or entry.get("baseSeverity") + score = cvss.get("baseScore") + vector = cvss.get("vectorString") + return ( + str(severity).upper() if severity else None, + float(score) if isinstance(score, (int, float)) else None, + vector, + ) + return None, None, None + + def _match_evidence(self, cve: Dict[str, Any]) -> tuple[str, list[str]]: + evidence: list[str] = [] + candidates = [] + for match in _all_cpe_matches(cve.get("configurations") or []): + if match.get("vulnerable") is False: + continue + criteria = match.get("criteria") or match.get("cpe23Uri") or "" + vendor, product, _ = _cpe_parts(criteria) + if self.vendor and vendor not in {self.vendor, "*", "-"}: + continue + if self.product and product not in {self.product, "*", "-"}: + continue + in_range = _version_in_range(self.version, match) if self.version else None + if in_range is True: + candidates.append(match) + elif in_range is None: + evidence.append(f"Software CPE matched ({criteria}) but version applicability could not be proven.") + if candidates: + matched = candidates[0] + criteria = matched.get("criteria") or matched.get("cpe23Uri") + evidence.append(f"Detected product/version matches an NVD vulnerable CPE range: {criteria}.") + for key in ("versionStartIncluding", "versionStartExcluding", "versionEndIncluding", "versionEndExcluding"): + if matched.get(key): + evidence.append(f"{key}={matched[key]}") + return "confirmed", evidence + if self.cpe: + evidence.append("NVD returned the exact CPE query, but no corroborating affected-version range was exposed.") + return "potential", evidence + if self.version and self.vendor and self.product: + evidence.append("Keyword result only; no exact vulnerable CPE/version range was confirmed.") + return "potential", evidence + evidence.append("Broad keyword correlation only; product/version identity is insufficient for a vulnerability claim.") + return "informational", evidence + + def check_nvd_api(self) -> list[Dict[str, Any]]: + """Fetch paginated NVD results and classify correlation strength.""" + self.cves = [] + self.errors = [] + start_index = 0 + total_results: Optional[int] = None + headers = {"Accept": "application/json", "User-Agent": "SpectraScan-CVE-Scanner/2.1"} + if self.api_key: + headers["apiKey"] = self.api_key + while start_index < self.max_records and (total_results is None or start_index < total_results): + params = self._params(start_index) + params["resultsPerPage"] = min(200, self.max_records - start_index) + try: + response = self.session.get(NVD_ENDPOINT, params=params, headers=headers, timeout=self.timeout) + if response.status_code == 429: + retry_after = response.headers.get("Retry-After", "1") + try: + delay = max(0.0, min(float(retry_after), 5.0)) + except (ValueError, TypeError): + delay = 1.0 + time.sleep(delay) + response = self.session.get(NVD_ENDPOINT, params=params, headers=headers, timeout=self.timeout) + if response.status_code in (403, 429): + self.errors.append({"category": "rate_limited", "status_code": response.status_code, "message": "NVD rate limit or access policy prevented further results."}) + break + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + raise ValueError("NVD returned a non-object JSON response") + total_results = int(payload.get("totalResults", 0)) + vulnerabilities = payload.get("vulnerabilities", []) + if not isinstance(vulnerabilities, list): + raise ValueError("NVD vulnerabilities field was not a list") + if not vulnerabilities: + break + for item in vulnerabilities: + cve = item.get("cve") if isinstance(item, dict) else None + if not isinstance(cve, dict) or not cve.get("id"): + continue + descriptions = cve.get("descriptions") or [] + description = next(( + row.get("value", "") for row in descriptions + if isinstance(row, dict) and row.get("lang") == "en" + ), "") + severity, score, vector = self._metrics(cve) + classification, evidence = self._match_evidence(cve) self.cves.append({ - 'id': cve_id, - 'severity': severity, - 'description': item['cve']['descriptions'][0]['value'][:100] + "id": cve["id"], "severity": severity or "UNKNOWN", + "cvss_score": score, "cvss_vector": vector, + "description": str(description)[:500], + "classification": classification, "evidence": evidence, + "reason": evidence[0] if evidence else "No usable version evidence was returned.", + "source": "NVD", }) - else: - logging.warning(f"NVD API Error: {response.status_code}") - except Exception as e: - logging.error(f"API Request Failed: {e}") + if len(self.cves) >= self.max_records: + break + start_index += len(vulnerabilities) + if start_index >= total_results: + break + except (requests.Timeout, requests.ConnectionError) as exc: + self.errors.append({"category": "network_error", "message": type(exc).__name__}) + break + except requests.HTTPError as exc: + status = getattr(exc.response, "status_code", None) + self.errors.append({"category": "http_error", "status_code": status, "message": "NVD returned an HTTP error."}) + break + except (ValueError, TypeError, KeyError): + self.errors.append({"category": "invalid_response", "message": "NVD response was malformed."}) + break + except requests.RequestException as exc: + self.errors.append({"category": "request_error", "message": type(exc).__name__}) + break + return list(self.cves) - def get_results(self): - return self.cves \ No newline at end of file + def get_results(self) -> list[Dict[str, Any]]: + return list(self.cves) From 66f160fbf4fb75acb72a29eecb0bf984bd38055c Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 19:01:39 +0330 Subject: [PATCH 05/46] fix: harden scanner modes, network sweeps, and report exports --- SpectraScan.py | 13720 +++++++++++++++++++++++------------------------ 1 file changed, 6817 insertions(+), 6903 deletions(-) diff --git a/SpectraScan.py b/SpectraScan.py index 71082f1..1c19dc9 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -1,6879 +1,6793 @@ -""" -SpectraScann - Optimized Edition -Features: SYN, UDP, OS Detection, SSL/TLS, HTTP Enum, Firewall Detection, - Ping Sweep, ARP Scan, Proxy Support, IDS Evasion, Rate Limiting, - Domain/IP/Phone/Email Scanning, EXIF Extraction, Link Sniffing. -""" -import socket -import concurrent.futures -import argparse -import sys -import time -import json -import random -import struct -import os -import logging -import ssl -import subprocess -import re -import csv -import ipaddress -import html -import urllib.parse -from functools import wraps -from datetime import datetime -from typing import List, Dict, Optional, Tuple -from urllib.request import urlopen, Request -from urllib.error import URLError, HTTPError -from rich.console import Console -from rich.table import Table -from rich.panel import Panel -from rich.text import Text -from rich.prompt import Prompt, Confirm -from rich import print as rprint -from rich.markdown import Markdown -from rich.live import Live +""" +SpectraScann - Optimized Edition +Features: SYN, UDP, OS Detection, SSL/TLS, HTTP Enum, Firewall Detection, + Ping Sweep, ARP Scan, Proxy Support, IDS Evasion, Rate Limiting, + Domain/IP/Phone/Email Scanning, EXIF Extraction, Link Sniffing. +""" +import socket +import concurrent.futures +import argparse +import sys +import time +import json +import random +import struct +import os +import logging +import math +import queue +import ssl +import subprocess +import re +import csv +import ipaddress +import html +import urllib.parse +from functools import wraps +from datetime import datetime +from typing import List, Dict, Optional, Tuple +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError +from rich.console import Console +from rich.table import Table +from rich.panel import Panel +from rich.text import Text +from rich.prompt import Prompt, Confirm +from rich import print as rprint +from rich.markdown import Markdown +from rich.live import Live from rich.progress import Progress, SpinnerColumn, TextColumn -from modules.async_scanner import AsyncPortScanner - -sys.path.append(os.path.join(os.path.dirname(__file__), "modules")) - -# Initialize Rich Console -console = Console() - - - -import hashlib -import html as html_mod -import ipaddress -import json -import os -import re -import socket -import ssl -import time -import urllib.parse -from datetime import datetime, timezone -from typing import Any, Dict, Iterable, List, Optional, Tuple - -# --------------------------------------------------------------------- -# Optional dependencies -# --------------------------------------------------------------------- - -try: - import socks # type: ignore - - SOCKS_AVAILABLE = True -except ImportError: - socks = None - SOCKS_AVAILABLE = False - -try: - import requests - - REQUESTS_AVAILABLE = True -except ImportError: - requests = None - REQUESTS_AVAILABLE = False - -try: - from cryptography import x509 - from cryptography.hazmat.backends import default_backend - - CRYPTOGRAPHY_AVAILABLE = True -except ImportError: - x509 = None - default_backend = None - CRYPTOGRAPHY_AVAILABLE = False - -try: - import dns.resolver # type: ignore - - DNS_AVAILABLE = True -except ImportError: - dns = None - DNS_AVAILABLE = False - -from rich.console import Console -from rich.panel import Panel -from rich.prompt import Confirm, Prompt -from rich.table import Table - -console = Console() - -# --------------------------------------------------------------------- -# Configuration -# --------------------------------------------------------------------- - -MODULE_NAME = "SpectraScan Dark Web Intelligence" -MODULE_VERSION = "2.0.0" - -TOR_SOCKS_HOST = os.getenv("TOR_HOST", "127.0.0.1") - -try: - TOR_SOCKS_PORT = int(os.getenv("TOR_PORT", "9050")) -except ValueError: - TOR_SOCKS_PORT = 9050 - -TOR_HTTP_PROXY = f"socks5h://{TOR_SOCKS_HOST}:{TOR_SOCKS_PORT}" - -DEFAULT_TIMEOUT = float(os.getenv("SPECTRASCAN_TIMEOUT", "20")) -MAX_HTTP_BODY = int(os.getenv("SPECTRASCAN_MAX_BODY", "500000")) -MAX_REDIRECTS = int(os.getenv("SPECTRASCAN_MAX_REDIRECTS", "5")) - -USER_AGENT = ( - f"SpectraScan/{MODULE_VERSION} " - "(authorized-security-research)" -) - -# --------------------------------------------------------------------- -# Regex -# --------------------------------------------------------------------- - -ONION_V3_REGEX = re.compile( - r"(?'\"\\]+", - re.IGNORECASE, -) - -BTC_BASE58_REGEX = re.compile( - r"^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$" -) - -BTC_BECH32_REGEX = re.compile( - r"^bc1[ac-hj-np-z02-9]{11,87}$", - re.IGNORECASE, -) - -BTC_TX_REGEX = re.compile( - r"^[a-fA-F0-9]{64}$" -) - -ETH_REGEX = re.compile( - r"^0x[a-fA-F0-9]{40}$" -) - -ETH_TX_REGEX = re.compile( - r"^0x[a-fA-F0-9]{64}$" -) - -XMR_REGEX = re.compile( - r"^4[0-9AB][0-9a-zA-Z]{93}$" -) - -LTC_REGEX = re.compile( - r"^[LM3][a-km-zA-HJ-NP-Z1-9]{26,33}$" -) - -MD5_REGEX = re.compile(r"^[a-fA-F0-9]{32}$") -SHA1_REGEX = re.compile(r"^[a-fA-F0-9]{40}$") -SHA256_REGEX = re.compile(r"^[a-fA-F0-9]{64}$") -SHA512_REGEX = re.compile(r"^[a-fA-F0-9]{128}$") - -PGP_BEGIN_REGEX = re.compile( - r"-----BEGIN PGP PUBLIC KEY BLOCK-----", - re.IGNORECASE, -) - -PHONE_REGEX = re.compile( - r"^\+?\d{1,3}" - r"[\s.-]?" - r"\(?\d{1,4}\)?" - r"[\s.-]?" - r"\d{3,4}" - r"[\s.-]?" - r"\d{3,4}$" -) - -USERNAME_REGEX = re.compile( - r"^[a-zA-Z][a-zA-Z0-9_.-]{2,31}$" -) - -DISPOSABLE_DOMAINS = { - "10minutemail.com", - "dispostable.com", - "fakeinbox.com", - "getnada.com", - "guerrillamail.com", - "maildrop.cc", - "mailinator.com", - "sharklasers.com", - "tempmail.com", - "throwaway.email", - "trashmail.com", - "yopmail.com", -} - -SUSPICIOUS_TLDS = { - "cf", - "click", - "ga", - "gq", - "icu", - "ml", - "online", - "rest", - "site", - "top", - "tk", - "xyz", -} - -SENSITIVE_HEADERS = { - "server", - "x-powered-by", - "x-aspnet-version", - "x-generator", -} - -SECURITY_HEADERS = { - "strict-transport-security": "HSTS", - "content-security-policy": "CSP", - "x-frame-options": "X-Frame-Options", - "x-content-type-options": "X-Content-Type-Options", - "referrer-policy": "Referrer-Policy", - "permissions-policy": "Permissions-Policy", - "cross-origin-opener-policy": "COOP", - "cross-origin-resource-policy": "CORP", - "cross-origin-embedder-policy": "COEP", -} - -COMMON_TECH_SIGNATURES = { - "WordPress": [ - r"/wp-content/", - r"/wp-includes/", - r"wp-json", - ], - "Drupal": [ - r"drupalSettings", - r"/sites/default/", - r"Drupal.settings", - ], - "Joomla": [ - r"/media/system/", - r"Joomla!", - ], - "PHP": [ - r"\.php(?:[?#]|$)", - r"PHPSESSID", - ], - "Laravel": [ - r"laravel_session", - r"Laravel", - ], - "Django": [ - r"csrftoken", - r"__django", - ], - "nginx": [ - r"nginx", - ], - "Apache": [ - r"Apache", - ], - "Cloudflare": [ - r"cloudflare", - r"cf-ray", - ], - "React": [ - r"react", - r"__NEXT_DATA__", - ], - "Next.js": [ - r"__NEXT_DATA__", - r"_next/static/", - ], - "Vue.js": [ - r"vue", - r"__vue__", - ], -} - -# --------------------------------------------------------------------- -# Public APIs -# --------------------------------------------------------------------- - -BLOCKSTREAM_API = "https://blockstream.info/api" -BLOCKCHAIR_API = "https://api.blockchair.com/bitcoin" -BLOCKCHAIN_INFO = "https://blockchain.info" -AHMIA_SEARCH = "https://ahmia.fi/search/?q=" - -# --------------------------------------------------------------------- -# Utility helpers -# --------------------------------------------------------------------- - - -def utc_now() -> str: - return datetime.now(timezone.utc).isoformat() - - -def safe_int(value: Any, default: Optional[int] = None) -> Optional[int]: - try: - return int(value) - except (TypeError, ValueError): - return default - - -def safe_float( - value: Any, - default: Optional[float] = None, -) -> Optional[float]: - try: - return float(value) - except (TypeError, ValueError): - return default - - -def normalize_target(target: str) -> str: - """ - Normalize a target without destroying URL path/query information. - """ - target = (target or "").strip() - - if not target: - return "" - - if "://" in target: - parsed = urllib.parse.urlsplit(target) - - host = parsed.hostname or "" - - if parsed.port: - host = f"{host}:{parsed.port}" - - path = parsed.path or "" - - normalized = f"{parsed.scheme.lower()}://{host}{path}" - - if parsed.query: - normalized += f"?{parsed.query}" - - return normalized.rstrip("/") - - return target.rstrip("/") - - -def normalize_domain(value: str) -> str: - value = value.strip().lower() - - if "://" in value: - value = urllib.parse.urlsplit(value).hostname or "" - - value = value.split("/", 1)[0] - value = value.split(":", 1)[0] - - return value.rstrip(".") - - -def is_valid_ipv4(value: str) -> bool: - try: - return isinstance(ipaddress.ip_address(value), ipaddress.IPv4Address) - except ValueError: - return False - - -def is_valid_ipv6(value: str) -> bool: - try: - return isinstance(ipaddress.ip_address(value), ipaddress.IPv6Address) - except ValueError: - return False - - -def dedupe(items: Iterable[str]) -> List[str]: - return sorted( - set( - x.strip() - for x in items - if x and x.strip() - ) - ) - - -# --------------------------------------------------------------------- -# Base58Check -# --------------------------------------------------------------------- - -BASE58_ALPHABET = ( - "123456789ABCDEFGHJKLMNPQRSTUVWXYZ" - "abcdefghijkmnopqrstuvwxyz" -) - - -def _base58_decode(value: str) -> bytes: - number = 0 - - for char in value: - number *= 58 - number += BASE58_ALPHABET.index(char) - - decoded = bytearray() - - while number: - decoded.append(number % 256) - number //= 256 - - leading_zeroes = 0 - - for char in value: - if char == "1": - leading_zeroes += 1 - else: - break - - return bytes( - b"\x00" * leading_zeroes - + bytes(reversed(decoded)) - ) - - -def validate_btc_base58(address: str) -> bool: - try: - if not BTC_BASE58_REGEX.fullmatch(address): - return False - - decoded = _base58_decode(address) - - if len(decoded) != 25: - return False - - version = decoded[0] - - if version not in (0x00, 0x05): - return False - - payload = decoded[:-4] - checksum = decoded[-4:] - - expected = hashlib.sha256( - hashlib.sha256(payload).digest() - ).digest()[:4] - - return checksum == expected - - except (ValueError, IndexError): - return False - - -# --------------------------------------------------------------------- -# Bech32 / Bech32m -# --------------------------------------------------------------------- - -BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" - - -def _bech32_polymod(values: Iterable[int]) -> int: - generators = [ - 0x3B6A57B2, - 0x26508E6D, - 0x1EA119FA, - 0x3D4233DD, - 0x2A1462B3, - ] - - checksum = 1 - - for value in values: - top = checksum >> 25 - - checksum = ( - (checksum & 0x1FFFFFF) << 5 - ) ^ value - - for index in range(5): - if (top >> index) & 1: - checksum ^= generators[index] - - return checksum - - -def _bech32_hrp_expand(hrp: str) -> List[int]: - return ( - [ord(char) >> 5 for char in hrp] - + [0] - + [ord(char) & 31 for char in hrp] - ) - - -def _bech32_decode( - value: str, -) -> Tuple[Optional[str], Optional[List[int]], Optional[str]]: - if not value: - return None, None, None - - if len(value) > 90: - return None, None, None - - if value.lower() != value and value.upper() != value: - return None, None, None - - value = value.lower() - - position = value.rfind("1") - - if position < 1: - return None, None, None - - if position + 7 > len(value): - return None, None, None - - data_part = value[position + 1:] - - if not all( - char in BECH32_CHARSET - for char in data_part - ): - return None, None, None - - hrp = value[:position] - - data = [ - BECH32_CHARSET.find(char) - for char in data_part - ] - - polymod = _bech32_polymod( - _bech32_hrp_expand(hrp) + data - ) - - if polymod == 1: - spec = "bech32" - elif polymod == 0x2BC830A3: - spec = "bech32m" - else: - return None, None, None - - return hrp, data[:-6], spec - - -def _convertbits( - data: Iterable[int], - frombits: int, - tobits: int, - pad: bool = True, -) -> Optional[List[int]]: - accumulator = 0 - bits = 0 - result: List[int] = [] - - max_value = (1 << tobits) - 1 - max_accumulator = ( - 1 << (frombits + tobits - 1) - ) - 1 - - for value in data: - if value < 0 or value >> frombits: - return None - - accumulator = ( - (accumulator << frombits) | value - ) & max_accumulator - - bits += frombits - - while bits >= tobits: - bits -= tobits - - result.append( - (accumulator >> bits) & max_value - ) - - if pad: - if bits: - result.append( - (accumulator << (tobits - bits)) - & max_value - ) - - elif bits >= frombits: - return None - - elif ( - (accumulator << (tobits - bits)) - & max_value - ): - return None - - return result - - -def validate_btc_bech32(address: str) -> bool: - try: - hrp, data, spec = _bech32_decode(address) - - if hrp != "bc" or data is None or spec is None: - return False - - if not data: - return False - - witness_version = data[0] - - if witness_version > 16: - return False - - program = _convertbits( - data[1:], - 5, - 8, - False, - ) - - if program is None: - return False - - if not 2 <= len(program) <= 40: - return False - - if witness_version == 0: - if spec != "bech32": - return False - - if len(program) not in (20, 32): - return False - - else: - if spec != "bech32m": - return False - - return True - - except Exception: - return False - - -def validate_btc_address(address: str) -> bool: - address = address.strip() - - if validate_btc_base58(address): - return True - - return validate_btc_bech32(address) - - -def btc_address_format(address: str) -> Optional[str]: - if not validate_btc_address(address): - return None - - if address.lower().startswith("bc1"): - return "Bech32 / SegWit" - - if address.startswith("1"): - return "Legacy P2PKH" - - if address.startswith("3"): - return "P2SH" - - return "Bitcoin" - - -# --------------------------------------------------------------------- -# Target detection -# --------------------------------------------------------------------- - - -def detect_target_type(target: str) -> Dict[str, Any]: - """ - Detect and normalize a target. - - This function remains compatible with the original SpectraScan API. - """ - - original = target or "" - - result: Dict[str, Any] = { - "input": original, - "normalized": normalize_target(original), - "type": "unknown", - "subtype": None, - "valid": False, - "confidence": 0, - "metadata": {}, - "warnings": [], - } - - if not original.strip(): - result["type"] = "empty" - return result - - normalized = result["normalized"] - plain = normalized - - # URL - if re.match(r"^https?://", plain, re.IGNORECASE): - parsed = urllib.parse.urlsplit(plain) - - hostname = parsed.hostname or "" - - if hostname.endswith(".onion"): - onion_type = ( - "v3" - if ONION_V3_REGEX.fullmatch(hostname) - else "v2" - if ONION_V2_REGEX.fullmatch(hostname) - else None - ) - - if onion_type: - result.update( - { - "type": "onion", - "subtype": onion_type, - "valid": True, - "confidence": 100, - "metadata": { - "onion": hostname.lower(), - "scheme": parsed.scheme.lower(), - "port": parsed.port, - "path": parsed.path or "/", - }, - } - ) - - if onion_type == "v2": - result["warnings"].append( - "Onion v2 is deprecated." - ) - - return result - - result.update( - { - "type": "url", - "valid": True, - "confidence": 98, - "metadata": { - "scheme": parsed.scheme.lower(), - "host": hostname.lower(), - "port": parsed.port, - "path": parsed.path or "/", - }, - } - ) - - return result - - # Onion hostname - onion_v3 = ONION_V3_REGEX.fullmatch(plain) - if onion_v3: - result.update( - { - "type": "onion", - "subtype": "v3", - "valid": True, - "confidence": 100, - "metadata": { - "onion": plain.lower(), - "length": 56, - }, - } - ) - return result - - onion_v2 = ONION_V2_REGEX.fullmatch(plain) - if onion_v2: - result.update( - { - "type": "onion", - "subtype": "v2", - "valid": True, - "confidence": 100, - "metadata": { - "onion": plain.lower(), - "length": 16, - }, - "warnings": [ - "Onion v2 is deprecated." - ], - } - ) - return result - - # Email - email_match = EMAIL_REGEX.fullmatch(plain) - - if email_match: - email = plain.lower() - domain = email.rsplit("@", 1)[1] - - result.update( - { - "type": "email", - "valid": True, - "confidence": 98, - "metadata": { - "email": email, - "domain": domain, - "disposable": ( - domain in DISPOSABLE_DOMAINS - ), - }, - } - ) - - if domain in DISPOSABLE_DOMAINS: - result["warnings"].append( - "Disposable email domain." - ) - - return result - - # IPv4 - if IPV4_REGEX.fullmatch(plain) and is_valid_ipv4(plain): - ip = ipaddress.ip_address(plain) - - result.update( - { - "type": "ipv4", - "valid": True, - "confidence": 99, - "metadata": { - "ip": str(ip), - "private": ip.is_private, - "loopback": ip.is_loopback, - "reserved": ip.is_reserved, - "global": ip.is_global, - }, - } - ) - - return result - - # IPv6 - if ":" in plain and is_valid_ipv6(plain): - ip = ipaddress.ip_address(plain) - - result.update( - { - "type": "ipv6", - "valid": True, - "confidence": 99, - "metadata": { - "ip": str(ip), - "private": ip.is_private, - "loopback": ip.is_loopback, - "reserved": ip.is_reserved, - "global": ip.is_global, - }, - } - ) - - return result - - # BTC - if validate_btc_address(plain): - result.update( - { - "type": "crypto", - "subtype": "btc", - "valid": True, - "confidence": 100, - "metadata": { - "format": btc_address_format(plain), - }, - } - ) - return result - - # ETH transaction hash / address - if ETH_TX_REGEX.fullmatch(plain): - result.update( - { - "type": "crypto", - "subtype": "eth_tx", - "valid": True, - "confidence": 99, - } - ) - return result - - if ETH_REGEX.fullmatch(plain): - result.update( - { - "type": "crypto", - "subtype": "eth", - "valid": True, - "confidence": 99, - "metadata": { - "format": "Ethereum address" - }, - } - ) - return result - - # XMR - if XMR_REGEX.fullmatch(plain): - result.update( - { - "type": "crypto", - "subtype": "xmr", - "valid": True, - "confidence": 75, - "metadata": { - "format": "Monero" - }, - "warnings": [ - "Monero validation is format-based." - ], - } - ) - return result - - # LTC - if LTC_REGEX.fullmatch(plain): - result.update( - { - "type": "crypto", - "subtype": "ltc", - "valid": True, - "confidence": 75, - "metadata": { - "format": "Litecoin" - }, - "warnings": [ - "Litecoin validation is format-based." - ], - } - ) - return result - - # Hashes - hash_types = ( - ("sha512", SHA512_REGEX), - ("sha256", SHA256_REGEX), - ("sha1", SHA1_REGEX), - ("md5", MD5_REGEX), - ) - - for name, pattern in hash_types: - if pattern.fullmatch(plain): - result.update( - { - "type": "hash", - "subtype": name, - "valid": True, - "confidence": 96, - } - ) - return result - - # PGP - if PGP_BEGIN_REGEX.search(original): - result.update( - { - "type": "pgp_key", - "valid": True, - "confidence": 100, - } - ) - return result - - # Phone - if PHONE_REGEX.fullmatch(plain): - digits = re.sub(r"\D", "", plain) - - if 7 <= len(digits) <= 15: - result.update( - { - "type": "phone", - "valid": True, - "confidence": 70, - "metadata": { - "digits": digits, - "length": len(digits), - }, - } - ) - return result - - # Domain - domain = normalize_domain(plain) - - if DOMAIN_REGEX.fullmatch(domain): - tld = domain.rsplit(".", 1)[-1] - - result.update( - { - "type": "domain", - "valid": True, - "confidence": 90, - "metadata": { - "domain": domain, - "tld": tld, - }, - } - ) - - if tld in SUSPICIOUS_TLDS: - result["warnings"].append( - f"Abuse-prone/suspicious TLD: .{tld}" - ) - - return result - - # Username - if USERNAME_REGEX.fullmatch(plain): - result.update( - { - "type": "username", - "valid": True, - "confidence": 45, - } - ) - - result["warnings"].append( - "Likely username; confirm through OSINT sources." - ) - - return result - - return result - - -# --------------------------------------------------------------------- -# IOC extraction -# --------------------------------------------------------------------- - - -def extract_iocs(text: str) -> Dict[str, Any]: - """ - Extract and normalize common public indicators from text. - """ - - text = text or "" - - urls = dedupe( - URL_REGEX.findall(text) - ) - - onions_v3 = dedupe( - m.group(0).lower() - for m in ONION_V3_REGEX.finditer(text) - ) - - onions_v2 = dedupe( - m.group(0).lower() - for m in ONION_V2_REGEX.finditer(text) - ) - - emails = dedupe( - m.group(0).lower() - for m in EMAIL_REGEX.finditer(text) - ) - - ipv4 = [] - - for match in IPV4_REGEX.finditer(text): - value = match.group(0) - - if is_valid_ipv4(value): - ipv4.append(value) - - ipv4 = dedupe(ipv4) - - ipv6 = [] - - for match in IPV6_REGEX.finditer(text): - value = match.group(0) - - if is_valid_ipv6(value): - ipv6.append(value) - - ipv6 = dedupe(ipv6) - - domains = [] - - for match in DOMAIN_REGEX.finditer(text): - value = match.group(0).lower() - - if not value.endswith(".onion"): - domains.append(value) - - domains = dedupe(domains) - - btc = [] - eth = [] - xmr = [] - ltc = [] - - tokens = re.findall( - r"[A-Za-z0-9]{20,100}", - text, - ) - - for token in tokens: - clean = token.strip(".,;:!?()[]{}<>\"'") - - if validate_btc_address(clean): - btc.append(clean) - - elif ETH_REGEX.fullmatch(clean): - eth.append(clean) - - elif XMR_REGEX.fullmatch(clean): - xmr.append(clean) - - elif LTC_REGEX.fullmatch(clean): - ltc.append(clean) - - hashes = { - "md5": [], - "sha1": [], - "sha256": [], - "sha512": [], - } - - for token in re.findall( - r"\b[a-fA-F0-9]{32,128}\b", - text, - ): - length = len(token) - - if length == 32: - hashes["md5"].append(token.lower()) - - elif length == 40: - hashes["sha1"].append(token.lower()) - - elif length == 64: - hashes["sha256"].append(token.lower()) - - elif length == 128: - hashes["sha512"].append(token.lower()) - - pgp_blocks = re.findall( - r"-----BEGIN PGP PUBLIC KEY BLOCK-----.*?" - r"-----END PGP PUBLIC KEY BLOCK-----", - text, - flags=re.IGNORECASE | re.DOTALL, - ) - - return { - "urls": dedupe(urls), - "onions": { - "v3": dedupe(onions_v3), - "v2": dedupe(onions_v2), - }, - "domains": dedupe(domains), - "ipv4": dedupe(ipv4), - "ipv6": dedupe(ipv6), - "emails": dedupe(emails), - "crypto": { - "btc": dedupe(btc), - "eth": dedupe(eth), - "xmr": dedupe(xmr), - "ltc": dedupe(ltc), - }, - "hashes": { - key: dedupe(value) - for key, value in hashes.items() - }, - "pgp_blocks": pgp_blocks, - "counts": { - "urls": len(urls), - "onions": len(onions_v3) + len(onions_v2), - "domains": len(domains), - "ipv4": len(ipv4), - "ipv6": len(ipv6), - "emails": len(emails), - "btc": len(btc), - "eth": len(eth), - "xmr": len(xmr), - "ltc": len(ltc), - "hashes": sum( - len(value) - for value in hashes.values() - ), - "pgp_blocks": len(pgp_blocks), - }, - } - - -def extract_onion_links(text: str) -> Dict[str, Any]: - iocs = extract_iocs(text) - - return { - "v3": iocs["onions"]["v3"], - "v2": iocs["onions"]["v2"], - "total": ( - len(iocs["onions"]["v3"]) - + len(iocs["onions"]["v2"]) - ), - } - - -# --------------------------------------------------------------------- -# Tor -# --------------------------------------------------------------------- - - -def _tor_socket( - timeout: float = DEFAULT_TIMEOUT, -): - if not SOCKS_AVAILABLE: - raise RuntimeError( - "PySocks is not installed." - ) - - sock = socks.socksocket() - - sock.set_proxy( - socks.PROXY_TYPE_SOCKS5, - TOR_SOCKS_HOST, - TOR_SOCKS_PORT, - rdns=True, - ) - - sock.settimeout(timeout) - - return sock - - -def check_tor_connection( - timeout: float = 15.0, -) -> Dict[str, Any]: - - result = { - "socks_reachable": False, - "tor_working": False, - "exit_ip": None, - "error": None, - "timestamp": utc_now(), - } - - if not SOCKS_AVAILABLE: - result["error"] = ( - "pysocks not installed." - ) - return result - - sock = None - - try: - sock = _tor_socket(timeout) - - sock.connect( - ("check.torproject.org", 443) - ) - - result["socks_reachable"] = True - - context = ssl.create_default_context() - - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - - with context.wrap_socket( - sock, - server_hostname="check.torproject.org", - ) as secure: - - request = ( - "GET /api/ip HTTP/1.1\r\n" - "Host: check.torproject.org\r\n" - f"User-Agent: {USER_AGENT}\r\n" - "Connection: close\r\n" - "\r\n" - ) - - secure.sendall( - request.encode() - ) - - chunks = [] - - while True: - chunk = secure.recv(8192) - - if not chunk: - break - - chunks.append(chunk) - - raw = b"".join(chunks) - - if b"\r\n\r\n" in raw: - body = raw.split( - b"\r\n\r\n", - 1, - )[1] - - try: - payload = json.loads( - body.decode( - "utf-8", - errors="ignore", - ) - ) - - result["exit_ip"] = ( - payload.get("IP") - or payload.get("ip") - ) - - except Exception: - pass - - result["tor_working"] = True - - except Exception as exc: - result["error"] = ( - f"{type(exc).__name__}: {exc}" - ) - - finally: - if sock is not None: - try: - sock.close() - except Exception: - pass - - return result - - -# --------------------------------------------------------------------- -# HTTP helpers -# --------------------------------------------------------------------- - - -def _parse_http_headers( - header_bytes: bytes, -) -> Tuple[str, Optional[int], Dict[str, str]]: - - text = header_bytes.decode( - "iso-8859-1", - errors="replace", - ) - - lines = text.split("\r\n") - - status_line = lines[0] if lines else "" - - status_code = None - - parts = status_line.split() - - if len(parts) >= 2: - status_code = safe_int(parts[1]) - - headers: Dict[str, str] = {} - - for line in lines[1:]: - if ":" not in line: - continue - - key, value = line.split( - ":", - 1, - ) - - headers[key.strip().lower()] = ( - value.strip() - ) - - return ( - status_line, - status_code, - headers, - ) - - -def _extract_title( - body: bytes, -) -> Optional[str]: - - try: - text = body.decode( - "utf-8", - errors="ignore", - ) - - match = re.search( - r"]*>(.*?)", - text, - flags=re.IGNORECASE | re.DOTALL, - ) - - if not match: - return None - - title = html_mod.unescape( - match.group(1) - ) - - title = re.sub( - r"\s+", - " ", - title, - ).strip() - - return title[:500] or None - - except Exception: - return None - - -def _security_header_analysis( - headers: Dict[str, str], - scheme: str, -) -> Dict[str, Any]: - - present = {} - missing = [] - - for header, label in SECURITY_HEADERS.items(): - if header in headers: - present[label] = headers[header] - else: - missing.append(label) - - score = 100 - - for header in missing: - if header == "HSTS" and scheme == "https": - score -= 20 - elif header in ( - "CSP", - "X-Frame-Options", - "X-Content-Type-Options", - ): - score -= 15 - else: - score -= 5 - - if scheme != "https": - score -= 20 - - return { - "score": max(0, min(100, score)), - "present": present, - "missing": missing, - } - - -def _technology_fingerprint( - body: bytes, - headers: Dict[str, str], -) -> List[Dict[str, Any]]: - - text = body.decode( - "utf-8", - errors="ignore", - ) - - combined = ( - text[:200000] - + "\n" - + "\n".join( - f"{k}: {v}" - for k, v in headers.items() - ) - ) - - findings = [] - - for technology, signatures in ( - COMMON_TECH_SIGNATURES.items() - ): - matches = [] - - for signature in signatures: - try: - if re.search( - signature, - combined, - flags=re.IGNORECASE, - ): - matches.append(signature) - except re.error: - continue - - if matches: - findings.append( - { - "technology": technology, - "confidence": min( - 95, - 50 + len(matches) * 15, - ), - "evidence": matches, - } - ) - - return findings - - -# --------------------------------------------------------------------- -# TLS -# --------------------------------------------------------------------- - - -def _parse_tls_certificate( - der: bytes, -) -> Dict[str, Any]: - - info: Dict[str, Any] = { - "sha256_fp": hashlib.sha256( - der - ).hexdigest(), - - "sha1_fp": hashlib.sha1( - der - ).hexdigest(), - - "cert_size": len(der), - } - - if not CRYPTOGRAPHY_AVAILABLE: - info["parser"] = "openssl/native" - return info - - try: - certificate = ( - x509.load_der_x509_certificate( - der, - default_backend(), - ) - ) - - info["subject"] = ( - certificate.subject.rfc4514_string() - ) - - info["issuer"] = ( - certificate.issuer.rfc4514_string() - ) - - info["serial"] = str( - certificate.serial_number - ) - - info["version"] = ( - str(certificate.version) - ) - - try: - san = certificate.extensions.get_extension_for_class( - x509.SubjectAlternativeName - ) - - info["san"] = [ - str(value) - for value in san.value - ] - - except Exception: - info["san"] = [] - - not_before = ( - certificate.not_valid_before_utc - if hasattr( - certificate, - "not_valid_before_utc", - ) - else certificate.not_valid_before.replace( - tzinfo=timezone.utc - ) - ) - - not_after = ( - certificate.not_valid_after_utc - if hasattr( - certificate, - "not_valid_after_utc", - ) - else certificate.not_valid_after.replace( - tzinfo=timezone.utc - ) - ) - - info["not_before"] = ( - not_before.isoformat() - ) - - info["not_after"] = ( - not_after.isoformat() - ) - - now = datetime.now(timezone.utc) - - days = ( - not_after - now - ).days - - info["days_to_expiry"] = days - info["expired"] = days < 0 - - info["self_signed"] = ( - certificate.subject - == certificate.issuer - ) - - except Exception as exc: - info["cert_error"] = str(exc) - - return info - - -# --------------------------------------------------------------------- -# Onion reconnaissance -# --------------------------------------------------------------------- - - -def _parse_onion_target( - target: str, - port: int = 443, - use_ssl: bool = True, -) -> Dict[str, Any]: - - target = target.strip() - - if "://" in target: - parsed = urllib.parse.urlsplit( - target - ) - - host = parsed.hostname - - if not host: - raise ValueError( - "Unable to parse hostname." - ) - - scheme = parsed.scheme.lower() - - use_ssl = scheme == "https" - - actual_port = ( - parsed.port - or (443 if use_ssl else 80) - ) - - path = parsed.path or "/" - - if parsed.query: - path += f"?{parsed.query}" - - return { - "host": host.lower(), - "port": actual_port, - "scheme": scheme, - "path": path, - } - - parsed = urllib.parse.urlsplit( - f"//{target}", - ) - - host = parsed.hostname - - if not host: - raise ValueError( - "Unable to parse hostname." - ) - - actual_port = ( - parsed.port - or port - ) - - path = parsed.path or "/" - - if parsed.query: - path += f"?{parsed.query}" - - return { - "host": host.lower(), - "port": actual_port, - "scheme": ( - "https" - if use_ssl - else "http" - ), - "path": path, - } - - -def grab_onion_banner( - onion: str, - port: int = 443, - use_ssl: bool = True, - timeout: float = DEFAULT_TIMEOUT, - max_body: int = MAX_HTTP_BODY, -) -> Dict[str, Any]: - - result: Dict[str, Any] = { - "target": onion, - "host": None, - "port": port, - "scheme": ( - "https" - if use_ssl - else "http" - ), - "reachable": False, - "tls": None, - "http": None, - "title": None, - "security": None, - "technologies": [], - "iocs": None, - "error": None, - "timestamp": utc_now(), - } - - if not SOCKS_AVAILABLE: - result["error"] = ( - "pysocks not installed." - ) - return result - - try: - parsed = _parse_onion_target( - onion, - port=port, - use_ssl=use_ssl, - ) - - host = parsed["host"] - actual_port = parsed["port"] - scheme = parsed["scheme"] - path = parsed["path"] - - result["host"] = host - result["port"] = actual_port - result["scheme"] = scheme - - if not ( - ONION_V3_REGEX.fullmatch(host) - or ONION_V2_REGEX.fullmatch(host) - ): - result["error"] = ( - f"Invalid onion hostname: {host}" - ) - return result - - sock = _tor_socket(timeout) - - try: - sock.connect( - (host, actual_port) - ) - - result["reachable"] = True - - stream = sock - - if scheme == "https": - context = ( - ssl.create_default_context() - ) - - context.check_hostname = False - context.verify_mode = ( - ssl.CERT_NONE - ) - - stream = context.wrap_socket( - sock, - server_hostname=host, - ) - - tls = { - "version": stream.version(), - "cipher": stream.cipher(), - } - - try: - der = ( - stream.getpeercert( - binary_form=True - ) - ) - - if der: - tls.update( - _parse_tls_certificate( - der - ) - ) - - except Exception as exc: - tls["cert_error"] = str( - exc - ) - - result["tls"] = tls - - request = ( - f"GET {path} HTTP/1.1\r\n" - f"Host: {host}\r\n" - f"User-Agent: {USER_AGENT}\r\n" - "Accept: text/html,application/xhtml+xml," - "application/xml;q=0.9,*/*;q=0.8\r\n" - "Connection: close\r\n" - "\r\n" - ) - - stream.sendall( - request.encode() - ) - - chunks = [] - total = 0 - - while total < max_body: - try: - chunk = stream.recv( - min(8192, max_body - total) - ) - except socket.timeout: - break - - if not chunk: - break - - chunks.append(chunk) - total += len(chunk) - - raw = b"".join(chunks) - - if b"\r\n\r\n" not in raw: - result["http"] = { - "raw_size": len(raw), - "error": ( - "No HTTP header delimiter." - ), - } - return result - - header_bytes, body = raw.split( - b"\r\n\r\n", - 1, - ) - - ( - status_line, - status_code, - headers, - ) = _parse_http_headers( - header_bytes - ) - - # Don't expose complete cookies. - safe_headers = dict(headers) - - if "set-cookie" in safe_headers: - safe_headers["set-cookie"] = ( - "[redacted]" - ) - - http_info = { - "status": status_line, - "status_code": status_code, - "headers": safe_headers, - "body_size": len(body), - "content_type": headers.get( - "content-type" - ), - "server": headers.get( - "server" - ), - "location": headers.get( - "location" - ), - } - - result["http"] = http_info - - result["title"] = _extract_title( - body - ) - - result["security"] = ( - _security_header_analysis( - headers, - scheme, - ) - ) - - result["technologies"] = ( - _technology_fingerprint( - body, - headers, - ) - ) - - result["iocs"] = extract_iocs( - body.decode( - "utf-8", - errors="ignore", - ) - ) - - finally: - try: - sock.close() - except Exception: - pass - - except Exception as exc: - result["error"] = ( - f"{type(exc).__name__}: {exc}" - ) - - return result - - -# --------------------------------------------------------------------- -# Redirect analysis -# --------------------------------------------------------------------- - - -def analyze_redirects( - target: str, - timeout: float = DEFAULT_TIMEOUT, - max_redirects: int = MAX_REDIRECTS, - through_tor: bool = False, -) -> Dict[str, Any]: - - result = { - "target": target, - "chain": [], - "final_url": None, - "redirect_count": 0, - "error": None, - } - - if not REQUESTS_AVAILABLE: - result["error"] = ( - "requests not installed." - ) - return result - - proxies = ( - { - "http": TOR_HTTP_PROXY, - "https": TOR_HTTP_PROXY, - } - if through_tor and SOCKS_AVAILABLE - else None - ) - - current = target - - for _ in range(max_redirects + 1): - try: - response = requests.get( - current, - timeout=timeout, - allow_redirects=False, - proxies=proxies, - headers={ - "User-Agent": USER_AGENT - }, - ) - - location = response.headers.get( - "location" - ) - - entry = { - "url": current, - "status_code": response.status_code, - "location": location, - } - - result["chain"].append(entry) - - if not location: - result["final_url"] = current - break - - current = urllib.parse.urljoin( - current, - location, - ) - - result["redirect_count"] += 1 - - except Exception as exc: - result["error"] = str(exc) - break - - return result - - -# --------------------------------------------------------------------- -# Domain / DNS intelligence -# --------------------------------------------------------------------- - - -def domain_intelligence( - domain: str, - timeout: float = DEFAULT_TIMEOUT, -) -> Dict[str, Any]: - - domain = normalize_domain(domain) - - result: Dict[str, Any] = { - "domain": domain, - "valid": bool( - DOMAIN_REGEX.fullmatch(domain) - ), - "records": {}, - "error": None, - "timestamp": utc_now(), - } - - if not result["valid"]: - result["error"] = ( - "Invalid domain." - ) - return result - - if not DNS_AVAILABLE: - result["error"] = ( - "dnspython not installed." - ) - return result - - record_types = ( - "A", - "AAAA", - "MX", - "NS", - "TXT", - "CNAME", - "SOA", - ) - - resolver = dns.resolver.Resolver() - resolver.timeout = timeout - resolver.lifetime = timeout - - for record_type in record_types: - try: - answers = resolver.resolve( - domain, - record_type, - ) - - values = [] - - for answer in answers: - values.append( - str(answer) - ) - - result["records"][ - record_type - ] = values - - except Exception: - result["records"][ - record_type - ] = [] - - txt_records = result[ - "records" - ].get("TXT", []) - - txt_joined = " ".join( - txt_records - ).lower() - - result["email_security"] = { - "spf": "v=spf1" in txt_joined, - "dmarc_hint": False, - } - - try: - dmarc = resolver.resolve( - f"_dmarc.{domain}", - "TXT", - ) - - result["email_security"][ - "dmarc_hint" - ] = any( - "v=dmarc1" - in str(record).lower() - for record in dmarc - ) - - except Exception: - pass - - return result - - -# --------------------------------------------------------------------- -# Email intelligence -# --------------------------------------------------------------------- - - -def email_intelligence( - email: str, - timeout: float = DEFAULT_TIMEOUT, -) -> Dict[str, Any]: - - email = email.strip().lower() - - detection = detect_target_type( - email - ) - - if detection["type"] != "email": - return { - "email": email, - "valid": False, - "error": "Invalid email address.", - } - - domain = detection[ - "metadata" - ]["domain"] - - result = { - "email": email, - "valid": True, - "domain": domain, - "disposable": ( - domain in DISPOSABLE_DOMAINS - ), - "domain_intelligence": None, - "timestamp": utc_now(), - } - - result["domain_intelligence"] = ( - domain_intelligence( - domain, - timeout=timeout, - ) - ) - - return result - - -# --------------------------------------------------------------------- -# Ahmia -# --------------------------------------------------------------------- - - -def ahmia_search( - query: str, - timeout: float = DEFAULT_TIMEOUT, -) -> Dict[str, Any]: - - if not REQUESTS_AVAILABLE: - return { - "error": ( - "requests not installed." - ) - } - - query = query.strip() - - if not query: - return { - "error": "Empty search query." - } - - try: - response = requests.get( - AHMIA_SEARCH - + urllib.parse.quote_plus(query), - timeout=timeout, - headers={ - "User-Agent": USER_AGENT - }, - ) - - if response.status_code != 200: - return { - "error": ( - f"Ahmia returned HTTP " - f"{response.status_code}" - ) - } - - links = extract_onion_links( - response.text - ) - - return { - "query": query, - "v3_links": links["v3"][:50], - "v2_links": links["v2"][:50], - "total": links["total"], - "source": "ahmia.fi", - "timestamp": utc_now(), - } - - except Exception as exc: - return { - "error": ( - f"Ahmia request failed: {exc}" - ) - } - - -# --------------------------------------------------------------------- -# BTC intelligence -# --------------------------------------------------------------------- - - -def btc_first_seen( - address: str, - timeout: float = DEFAULT_TIMEOUT, -) -> Dict[str, Any]: - - result: Dict[str, Any] = { - "address": address, - "valid": False, - "first_seen": None, - "first_seen_timestamp": None, - "first_seen_block": None, - "last_seen": None, - "tx_count": None, - "total_received_btc": None, - "total_sent_btc": None, - "balance_btc": None, - "age_days": None, - "age_years": None, - "source": None, - "error": None, - "timestamp": utc_now(), - } - - address = address.strip() - - if not validate_btc_address(address): - result["error"] = ( - "Invalid BTC address." - ) - return result - - result["valid"] = True - - if not REQUESTS_AVAILABLE: - result["error"] = ( - "requests not installed." - ) - return result - - proxies = ( - { - "http": TOR_HTTP_PROXY, - "https": TOR_HTTP_PROXY, - } - if SOCKS_AVAILABLE - else None - ) - - headers = { - "User-Agent": USER_AGENT - } - - def set_age(timestamp: int) -> None: - age_days = max( - 0, - int( - ( - time.time() - - timestamp - ) - / 86400 - ), - ) - - result["age_days"] = age_days - result["age_years"] = round( - age_days / 365.25, - 2, - ) - - # Blockchair - try: - response = requests.get( - f"{BLOCKCHAIR_API}/dashboards/address/" - f"{address}", - timeout=timeout, - proxies=proxies, - headers=headers, - ) - - if response.status_code == 200: - payload = response.json() - - data = payload.get( - "data", - {}, - ).get( - address, - {}, - ) - - address_meta = data.get( - "address", - {}, - ) - - chain = data.get( - "chain_stats", - {}, - ) - - first_seen = address_meta.get( - "first_seen_receiving" - ) - - if first_seen: - result["first_seen"] = ( - first_seen - ) - - try: - parsed = datetime.strptime( - first_seen, - "%Y-%m-%d %H:%M:%S", - ).replace( - tzinfo=timezone.utc - ) - - timestamp = int( - parsed.timestamp() - ) - - result[ - "first_seen_timestamp" - ] = timestamp - - set_age(timestamp) - - except Exception: - pass - - result["last_seen"] = ( - address_meta.get( - "last_seen_receiving" - ) - ) - - funded = ( - chain.get( - "funded_txo_sum" - ) - or 0 - ) - - spent = ( - chain.get( - "spent_txo_sum" - ) - or 0 - ) - - result["tx_count"] = ( - chain.get("tx_count") - ) - - result[ - "total_received_btc" - ] = funded / 1e8 - - result[ - "total_sent_btc" - ] = spent / 1e8 - - result[ - "balance_btc" - ] = (funded - spent) / 1e8 - - result["source"] = ( - "blockchair" - ) - - return result - - except Exception: - pass - - # Blockstream - try: - first_tx = None - cursor = None - - for _ in range(20): - url = ( - f"{BLOCKSTREAM_API}/address/" - f"{address}/txs" - ) - - if cursor: - url += ( - f"/chain/{cursor}" - ) - - response = requests.get( - url, - timeout=timeout, - proxies=proxies, - headers=headers, - ) - - if response.status_code != 200: - break - - transactions = response.json() - - if not transactions: - break - - first_tx = transactions[-1] - - if len(transactions) < 25: - break - - cursor = transactions[ - -1 - ].get("txid") - - if not cursor: - break - - if first_tx: - status = first_tx.get( - "status", - {}, - ) - - block_time = status.get( - "block_time" - ) - - if block_time: - result[ - "first_seen_timestamp" - ] = block_time - - result["first_seen"] = ( - datetime.fromtimestamp( - block_time, - timezone.utc, - ).isoformat() - ) - - set_age(block_time) - - result[ - "first_seen_block" - ] = status.get( - "block_height" - ) - - response = requests.get( - f"{BLOCKSTREAM_API}/address/" - f"{address}", - timeout=timeout, - proxies=proxies, - headers=headers, - ) - - if response.status_code == 200: - stats = response.json() - - chain = stats.get( - "chain_stats", - {}, - ) - - funded = ( - chain.get( - "funded_txo_sum" - ) - or 0 - ) - - spent = ( - chain.get( - "spent_txo_sum" - ) - or 0 - ) - - result["tx_count"] = ( - chain.get("tx_count") - ) - - result[ - "total_received_btc" - ] = funded / 1e8 - - result[ - "total_sent_btc" - ] = spent / 1e8 - - result[ - "balance_btc" - ] = (funded - spent) / 1e8 - - result["source"] = ( - "blockstream" - ) - - return result - - except Exception: - pass - - # Blockchain.info - try: - response = requests.get( - f"{BLOCKCHAIN_INFO}/rawaddr/" - f"{address}?limit=50", - timeout=timeout, - proxies=proxies, - headers=headers, - ) - - if response.status_code == 200: - data = response.json() - - transactions = data.get( - "txs", - [], - ) - - if transactions: - first_tx = transactions[-1] - - timestamp = first_tx.get( - "time" - ) - - if timestamp: - result[ - "first_seen_timestamp" - ] = timestamp - - result["first_seen"] = ( - datetime.fromtimestamp( - timestamp, - timezone.utc, - ).isoformat() - ) - - set_age(timestamp) - - result[ - "first_seen_block" - ] = ( - first_tx.get( - "block_index" - ) - or first_tx.get( - "block_height" - ) - ) - - result[ - "total_received_btc" - ] = ( - data.get( - "total_received", - 0, - ) - / 1e8 - ) - - result[ - "total_sent_btc" - ] = ( - data.get( - "total_sent", - 0, - ) - / 1e8 - ) - - result[ - "balance_btc" - ] = ( - data.get( - "final_balance", - 0, - ) - / 1e8 - ) - - result["tx_count"] = data.get( - "n_tx", - 0, - ) - - result["source"] = ( - "blockchain.info" - ) - - return result - - except Exception: - pass - - result["error"] = ( - "All BTC APIs failed." - ) - - return result - - -# --------------------------------------------------------------------- -# Risk scoring -# --------------------------------------------------------------------- - - -def score_btc_risk( - btc_data: Dict[str, Any], -) -> Dict[str, Any]: - - risk = { - "score": 0, - "level": "LOW", - "confidence": 50, - "factors": [], - } - - if not btc_data.get("valid"): - risk["level"] = "UNKNOWN" - risk["confidence"] = 0 - return risk - - age = btc_data.get( - "age_days" - ) - - tx_count = ( - btc_data.get("tx_count") - or 0 - ) - - balance = ( - btc_data.get("balance_btc") - or 0 - ) - - if age is not None: - if age < 1: - risk["score"] += 30 - risk["factors"].append( - "Address first observed less than 1 day ago." - ) - - elif age < 7: - risk["score"] += 15 - risk["factors"].append( - "Very recently observed address." - ) - - elif age < 30: - risk["score"] += 5 - risk["factors"].append( - "Recently observed address." - ) - - if tx_count == 0: - risk["score"] += 5 - risk["factors"].append( - "No observed transactions." - ) - - elif tx_count > 1000: - risk["score"] -= 10 - risk["factors"].append( - "High transaction volume; " - "could represent a service or exchange." - ) - - if balance > 10 and tx_count < 5: - risk["score"] += 10 - risk["factors"].append( - "High balance with low transaction count." - ) - - risk["score"] = max( - 0, - min( - 100, - risk["score"], - ), - ) - - if risk["score"] >= 60: - risk["level"] = "HIGH" - - elif risk["score"] >= 30: - risk["level"] = "MEDIUM" - - else: - risk["level"] = "LOW" - - risk["confidence"] = min( - 95, - 50 + len( - risk["factors"] - ) * 10, - ) - - return risk - - -def calculate_target_risk( - detection: Dict[str, Any], - recon: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - - score = 0 - factors: List[str] = [] - - if not detection.get("valid"): - return { - "score": 0, - "level": "UNKNOWN", - "confidence": 0, - "factors": [], - } - - target_type = detection.get( - "type" - ) - - warnings = detection.get( - "warnings", - [], - ) - - score += min( - len(warnings) * 5, - 20, - ) - - factors.extend( - warnings - ) - - if target_type == "onion": - score += 10 - factors.append( - "Target is a Tor onion service." - ) - - if detection.get( - "subtype" - ) == "v2": - score += 20 - factors.append( - "Deprecated onion v2 service." - ) - - elif target_type == "email": - if detection.get( - "metadata", - {}, - ).get("disposable"): - score += 25 - factors.append( - "Disposable email domain." - ) - - elif target_type == "domain": - tld = detection.get( - "metadata", - {}, - ).get("tld") - - if tld in SUSPICIOUS_TLDS: - score += 15 - factors.append( - f"Abuse-prone TLD: .{tld}" - ) - - if recon: - security = recon.get( - "security" - ) - - if security: - security_score = ( - security.get( - "score", - 100, - ) - ) - - if security_score < 50: - score += 15 - factors.append( - "Weak HTTP security-header posture." - ) - - tls = recon.get("tls") - - if tls and tls.get("expired"): - score += 20 - factors.append( - "Expired TLS certificate." - ) - - score = max( - 0, - min( - 100, - score, - ), - ) - - if score >= 60: - level = "HIGH" - elif score >= 30: - level = "MEDIUM" - else: - level = "LOW" - - return { - "score": score, - "level": level, - "confidence": min( - 95, - 50 + len(factors) * 8, - ), - "factors": factors, - } - - -# --------------------------------------------------------------------- -# Full target reconnaissance -# --------------------------------------------------------------------- - - -def full_recon( - target: str, - timeout: float = DEFAULT_TIMEOUT, - perform_network: bool = True, -) -> Dict[str, Any]: - - started = time.time() - - detection = detect_target_type( - target - ) - - report: Dict[str, Any] = { - "tool": "SpectraScan", - "module": MODULE_NAME, - "module_version": MODULE_VERSION, - "timestamp": utc_now(), - "target": target, - "detection": detection, - "recon": {}, - "iocs": None, - "risk": None, - "errors": [], - } - - if not detection.get("valid"): - report["risk"] = { - "score": 0, - "level": "UNKNOWN", - "confidence": 0, - "factors": [ - "Target could not be validated." - ], - } - - return report - - target_type = detection["type"] - subtype = detection.get("subtype") - - if not perform_network: - report["risk"] = ( - calculate_target_risk( - detection - ) - ) - return report - - try: - if target_type == "onion": - report["recon"] = ( - grab_onion_banner( - target, - timeout=timeout, - ) - ) - - elif target_type == "domain": - report["recon"] = ( - domain_intelligence( - target, - timeout=timeout, - ) - ) - - elif target_type == "url": - report["recon"] = ( - analyze_redirects( - target, - timeout=timeout, - ) - ) - - elif target_type == "email": - report["recon"] = ( - email_intelligence( - target, - timeout=timeout, - ) - ) - - elif ( - target_type == "crypto" - and subtype == "btc" - ): - btc = btc_first_seen( - target, - timeout=timeout, - ) - - report["recon"] = btc - - report["risk"] = ( - score_btc_risk(btc) - ) - - elif target_type in ( - "hash", - "username", - "phone", - "ipv4", - "ipv6", - "pgp_key", - ): - report["recon"] = { - "note": ( - "Target recognized. " - "No intrusive lookup performed." - ) - } - - except Exception as exc: - report["errors"].append( - f"{type(exc).__name__}: {exc}" - ) - - if report["risk"] is None: - report["risk"] = ( - calculate_target_risk( - detection, - report.get("recon"), - ) - ) - - report["duration_seconds"] = round( - time.time() - started, - 3, - ) - - return report - - -# --------------------------------------------------------------------- -# JSON reporting -# --------------------------------------------------------------------- - - -def save_json_report( - report: Dict[str, Any], - output_path: str, -) -> str: - - output_path = os.path.abspath( - os.path.expanduser( - output_path - ) - ) - - directory = os.path.dirname( - output_path - ) - - if directory: - os.makedirs( - directory, - exist_ok=True, - ) - - with open( - output_path, - "w", - encoding="utf-8", - ) as file: - json.dump( - report, - file, - indent=2, - ensure_ascii=False, - default=str, - ) - - return output_path - - -# --------------------------------------------------------------------- -# Display -# --------------------------------------------------------------------- - - -def display_detection( - detection: Dict[str, Any], -) -> None: - - if detection.get("type") == "empty": - console.print( - "[red]No target provided.[/red]" - ) - return - - target_type = detection.get( - "type", - "unknown", - ) - - if target_type == "unknown": - console.print( - Panel( - f"[red]Unknown target[/red]\n" - f"{detection.get('input', '')}", - title="Detection", - border_style="red", - ) - ) - return - - colors = { - "onion": "magenta", - "crypto": "yellow", - "email": "cyan", - "ipv4": "blue", - "ipv6": "blue", - "hash": "green", - "domain": "cyan", - "url": "cyan", - "phone": "white", - "username": "white", - "pgp_key": "green", - } - - color = colors.get( - target_type, - "white", - ) - - table = Table( - title="Target Detection", - border_style=color, - show_header=False, - ) - - table.add_column( - "Field", - style="bold", - ) - - table.add_column("Value") - - label = target_type.upper() - - if detection.get("subtype"): - label += ( - f" ({detection['subtype'].upper()})" - ) - - table.add_row( - "Type", - f"[{color}]{label}[/{color}]", - ) - - table.add_row( - "Confidence", - f"{detection.get('confidence', 0)}%", - ) - - table.add_row( - "Valid", - ( - "[green]YES[/green]" - if detection.get("valid") - else "[red]NO[/red]" - ), - ) - - if detection.get("normalized"): - table.add_row( - "Normalized", - detection["normalized"][:120], - ) - - for key, value in detection.get( - "metadata", - {}, - ).items(): - - if isinstance(value, list): - value = ", ".join( - str(x) - for x in value[:5] - ) - - table.add_row( - key, - str(value)[:120], - ) - - for warning in detection.get( - "warnings", - [], - ): - table.add_row( - "[yellow]![/yellow]", - f"[yellow]{warning}[/yellow]", - ) - - console.print(table) - - -def display_onion_banner( - banner: Dict[str, Any], -) -> None: - - if banner.get("error"): - console.print( - Panel( - f"[red]{banner['error']}[/red]", - title=( - f"Onion {banner.get('host', '?')}" - ), - border_style="red", - ) - ) - return - - table = Table( - title=( - f"Onion Recon — " - f"{banner.get('host', '?')}:" - f"{banner.get('port', '?')}" - ), - border_style="magenta", - show_header=False, - ) - - table.add_column( - "Field", - style="bold magenta", - ) - - table.add_column("Value") - - table.add_row( - "Reachable", - ( - "[green]YES[/green]" - if banner.get("reachable") - else "[red]NO[/red]" - ), - ) - - table.add_row( - "Scheme", - str( - banner.get( - "scheme", - "?", - ) - ).upper(), - ) - - if banner.get("title"): - table.add_row( - "Title", - banner["title"], - ) - - tls = banner.get("tls") - - if tls: - if tls.get("version"): - table.add_row( - "TLS", - str(tls["version"]), - ) - - if tls.get("cipher"): - table.add_row( - "Cipher", - str(tls["cipher"]), - ) - - if tls.get("sha256_fp"): - table.add_row( - "Certificate SHA-256", - tls["sha256_fp"], - ) - - if tls.get("subject"): - table.add_row( - "Subject", - tls["subject"][:100], - ) - - if tls.get("issuer"): - table.add_row( - "Issuer", - tls["issuer"][:100], - ) - - if tls.get("days_to_expiry") is not None: - days = tls[ - "days_to_expiry" - ] - - color = ( - "red" - if days < 0 - else "yellow" - if days < 30 - else "green" - ) - - table.add_row( - "Certificate Expiry", - f"[{color}]{days} days[/{color}]", - ) - - http = banner.get("http") - - if http: - status = http.get( - "status_code" - ) - - if status: - color = ( - "green" - if 200 <= status < 300 - else "yellow" - if 300 <= status < 400 - else "red" - ) - - table.add_row( - "HTTP", - f"[{color}]{status}[/{color}]", - ) - - if http.get("server"): - table.add_row( - "Server", - str( - http["server"] - )[:100], - ) - - if http.get("content_type"): - table.add_row( - "Content-Type", - str( - http["content_type"] - )[:100], - ) - - table.add_row( - "Body Size", - f"{http.get('body_size', 0):,} bytes", - ) - - security = banner.get( - "security" - ) - - if security: - table.add_row( - "Security Score", - f"{security.get('score', 0)}/100", - ) - - console.print(table) - - technologies = banner.get( - "technologies", - [], - ) - - if technologies: - tech_table = Table( - title="Technology Fingerprint", - border_style="cyan", - ) - - tech_table.add_column( - "Technology" - ) - - tech_table.add_column( - "Confidence" - ) - - for item in technologies: - tech_table.add_row( - item["technology"], - f"{item['confidence']}%", - ) - - console.print( - tech_table - ) - - iocs = banner.get("iocs") - - if iocs: - display_ioc_summary(iocs) - - -def display_ioc_summary( - iocs: Dict[str, Any], -) -> None: - - counts = iocs.get( - "counts", - {}, - ) - - table = Table( - title="IOC Summary", - border_style="green", - ) - - table.add_column("IOC") - table.add_column("Count") - - labels = ( - ("URLs", "urls"), - ("Onion", "onions"), - ("Domains", "domains"), - ("IPv4", "ipv4"), - ("IPv6", "ipv6"), - ("Emails", "emails"), - ("BTC", "btc"), - ("ETH", "eth"), - ("XMR", "xmr"), - ("LTC", "ltc"), - ("Hashes", "hashes"), - ("PGP", "pgp_blocks"), - ) - - for label, key in labels: - count = counts.get( - key, - 0, - ) - - if count: - table.add_row( - label, - str(count), - ) - - console.print(table) - - -def display_btc( - data: Dict[str, Any], - risk: Optional[Dict[str, Any]] = None, -) -> None: - - if not data.get("valid"): - console.print( - Panel( - f"[red]{data.get('error', 'Invalid BTC address')}[/red]", - title="BTC Intelligence", - border_style="red", - ) - ) - return - - table = Table( - title=( - f"BTC Intelligence — " - f"{data['address']}" - ), - border_style="yellow", - show_header=False, - ) - - table.add_column( - "Field", - style="bold yellow", - ) - - table.add_column("Value") - - if data.get("first_seen"): - table.add_row( - "First Seen", - str(data["first_seen"]), - ) - - if data.get("first_seen_block"): - table.add_row( - "First Block", - f"#{data['first_seen_block']:,}", - ) - - if data.get("age_days") is not None: - table.add_row( - "Age", - f"{data['age_days']:,} days " - f"(~{data.get('age_years', 0)} years)", - ) - - table.add_row( - "Transactions", - f"{data.get('tx_count') or 0:,}", - ) - - table.add_row( - "Received", - f"{data.get('total_received_btc') or 0:.8f} BTC", - ) - - table.add_row( - "Sent", - f"{data.get('total_sent_btc') or 0:.8f} BTC", - ) - - table.add_row( - "Balance", - f"{data.get('balance_btc') or 0:.8f} BTC", - ) - - table.add_row( - "Source", - str( - data.get( - "source", - "unknown", - ) - ), - ) - - console.print(table) - - if risk: - color = { - "HIGH": "red", - "MEDIUM": "yellow", - "LOW": "green", - }.get( - risk.get("level"), - "white", - ) - - console.print( - Panel( - f"Score: [bold]{risk.get('score', 0)}/100[/bold]\n" - f"Level: [{color}]" - f"{risk.get('level', 'UNKNOWN')}" - f"[/{color}]\n" - f"Confidence: " - f"{risk.get('confidence', 0)}%", - title="BTC Risk Heuristics", - border_style=color, - ) - ) - - for factor in risk.get( - "factors", - [], - ): - console.print( - f" [yellow]•[/yellow] {factor}" - ) - - -# --------------------------------------------------------------------- -# Menu helpers -# --------------------------------------------------------------------- - - -def _prompt( - text: str, - default: str = "", -) -> str: - - value = Prompt.ask( - f"[cyan]{text}[/cyan]", - default=default, - ) - - return value.strip() - - -def _save_report_interactive( - report: Dict[str, Any], -) -> None: - - path = _prompt( - "Output JSON path", - "spectrascan_darkweb_report.json", - ) - - try: - saved = save_json_report( - report, - path, - ) - - console.print( - f"[green]Report saved:[/green] " - f"{saved}" - ) - - except Exception as exc: - console.print( - f"[red]Unable to save report: " - f"{exc}[/red]" - ) - - -# --------------------------------------------------------------------- -# Interactive menu -# --------------------------------------------------------------------- - - -def run_darkweb_menu() -> None: - """ - Main Dark Web Intelligence interactive menu. - """ - - console.print( - Panel( - f"[bold magenta]" - f"SPECTRASCAN DARK WEB INTELLIGENCE" - f"[/bold magenta]\n" - f"[dim]Module {MODULE_VERSION}[/dim]", - border_style="magenta", - ) - ) - - if not SOCKS_AVAILABLE: - console.print( - "[yellow]![/yellow] " - "PySocks unavailable — " - "Tor operations disabled." - ) - - if not REQUESTS_AVAILABLE: - console.print( - "[yellow]![/yellow] " - "Requests unavailable — " - "HTTP/API operations disabled." - ) - - if not DNS_AVAILABLE: - console.print( - "[yellow]![/yellow] " - "dnspython unavailable — " - "DNS intelligence disabled." - ) - - while True: - console.print( - Panel( - """ -[bold magenta]DARK WEB INTELLIGENCE[/bold magenta] - -[green]1.[/green] Auto Detect Target -[green]2.[/green] Full Recon -[green]3.[/green] Onion Banner / Web Recon -[green]4.[/green] Extract IOCs -[green]5.[/green] BTC Intelligence -[green]6.[/green] Domain / DNS Intelligence -[green]7.[/green] Email Intelligence -[green]8.[/green] Ahmia Search -[green]9.[/green] Redirect Analysis -[green]10.[/green] Tor Connectivity -[green]11.[/green] Save JSON Report -[red]12.[/red] Exit - """, - border_style="magenta", - ) - ) - - choice = Prompt.ask( - "[bold magenta]Select option[/bold magenta]", - choices=[ - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "10", - "11", - "12", - ], - default="1", - ) - - try: - # --------------------------------------------------------- - # Detection - # --------------------------------------------------------- - - if choice == "1": - target = _prompt( - "Target" - ) - - if target: - display_detection( - detect_target_type( - target - ) - ) - - # --------------------------------------------------------- - # Full recon - # --------------------------------------------------------- - - elif choice == "2": - target = _prompt( - "Target" - ) - - if not target: - continue - - with console.status( - "[magenta]Running full reconnaissance...[/magenta]" - ): - report = full_recon( - target - ) - - display_detection( - report["detection"] - ) - - if report[ - "detection" - ]["type"] == "onion": - display_onion_banner( - report["recon"] - ) - - elif ( - report[ - "detection" - ]["subtype"] - == "btc" - ): - display_btc( - report["recon"], - report["risk"], - ) - - else: - console.print( - Panel( - json.dumps( - report[ - "recon" - ], - indent=2, - default=str, - )[:10000], - title="Recon Result", - border_style="cyan", - ) - ) - - risk = report.get( - "risk" - ) - - if risk: - console.print( - Panel( - f"Risk: " - f"[bold]{risk.get('level')}[/bold]\n" - f"Score: " - f"{risk.get('score')}/100\n" - f"Confidence: " - f"{risk.get('confidence')}%", - title="Risk", - border_style=( - "red" - if risk.get( - "level" - ) == "HIGH" - else "yellow" - ), - ) - ) - - if Confirm.ask( - "Save JSON report?", - default=False, - ): - _save_report_interactive( - report - ) - - # --------------------------------------------------------- - # Onion - # --------------------------------------------------------- - - elif choice == "3": - if not SOCKS_AVAILABLE: - console.print( - "[red]PySocks required.[/red]" - ) - continue - - target = _prompt( - "Onion URL / hostname" - ) - - if not target: - continue - - with console.status( - "[magenta]Performing onion reconnaissance...[/magenta]" - ): - result = ( - grab_onion_banner( - target - ) - ) - - display_onion_banner( - result - ) - - # --------------------------------------------------------- - # IOC - # --------------------------------------------------------- - - elif choice == "4": - console.print( - "[dim]Paste text. " - "Press Enter twice to finish.[/dim]" - ) - - lines = [] - empty_lines = 0 - - while True: - try: - line = input() - except EOFError: - break - - if not line: - empty_lines += 1 - - if empty_lines >= 2: - break - - continue - - empty_lines = 0 - lines.append(line) - - iocs = extract_iocs( - "\n".join(lines) - ) - - display_ioc_summary( - iocs - ) - - if Confirm.ask( - "Show extracted values?", - default=True, - ): - console.print( - Panel( - json.dumps( - iocs, - indent=2, - ensure_ascii=False, - ), - title="Extracted IOCs", - border_style="green", - ) - ) - - # --------------------------------------------------------- - # BTC - # --------------------------------------------------------- - - elif choice == "5": - address = _prompt( - "BTC address" - ) - - if not address: - continue - - with console.status( - "[yellow]Querying public blockchain data...[/yellow]" - ): - data = btc_first_seen( - address - ) - - risk = ( - score_btc_risk(data) - if data.get("valid") - else None - ) - - display_btc( - data, - risk, - ) - - # --------------------------------------------------------- - # Domain - # --------------------------------------------------------- - - elif choice == "6": - domain = _prompt( - "Domain" - ) - - if not domain: - continue - - with console.status( - "[cyan]Resolving DNS records...[/cyan]" - ): - result = ( - domain_intelligence( - domain - ) - ) - - console.print( - Panel( - json.dumps( - result, - indent=2, - default=str, - ), - title="Domain Intelligence", - border_style="cyan", - ) - ) - - # --------------------------------------------------------- - # Email - # --------------------------------------------------------- - - elif choice == "7": - email = _prompt( - "Email" - ) - - if not email: - continue - - with console.status( - "[cyan]Analyzing email domain...[/cyan]" - ): - result = ( - email_intelligence( - email - ) - ) - - console.print( - Panel( - json.dumps( - result, - indent=2, - default=str, - ), - title="Email Intelligence", - border_style="cyan", - ) - ) - - # --------------------------------------------------------- - # Ahmia - # --------------------------------------------------------- - - elif choice == "8": - query = _prompt( - "Ahmia search" - ) - - if not query: - continue - - with console.status( - "[cyan]Searching Ahmia...[/cyan]" - ): - result = ( - ahmia_search( - query - ) - ) - - if result.get("error"): - console.print( - f"[red]{result['error']}[/red]" - ) - continue - - console.print( - f"[green]Found " - f"{result['total']} " - f".onion indicators.[/green]" - ) - - if result["v3_links"]: - console.print( - Panel( - "\n".join( - result[ - "v3_links" - ] - ), - title="Onion v3", - border_style="magenta", - ) - ) - - if result["v2_links"]: - console.print( - Panel( - "\n".join( - result[ - "v2_links" - ] - ), - title="Onion v2", - border_style="red", - ) - ) - - # --------------------------------------------------------- - # Redirects - # --------------------------------------------------------- - - elif choice == "9": - target = _prompt( - "HTTP/HTTPS URL" - ) - - if not target: - continue - - with console.status( - "[cyan]Analyzing redirects...[/cyan]" - ): - result = ( - analyze_redirects( - target - ) - ) - - table = Table( - title="Redirect Chain", - border_style="cyan", - ) - - table.add_column( - "#" - ) - - table.add_column( - "Status" - ) - - table.add_column( - "URL" - ) - - for index, item in enumerate( - result["chain"], - start=1, - ): - table.add_row( - str(index), - str( - item[ - "status_code" - ] - ), - item["url"][:150], - ) - - console.print( - table - ) - - if result.get( - "final_url" - ): - console.print( - f"[green]Final URL:[/green] " - f"{result['final_url']}" - ) - - if result.get("error"): - console.print( - f"[red]{result['error']}[/red]" - ) - - # --------------------------------------------------------- - # Tor - # --------------------------------------------------------- - - elif choice == "10": - with console.status( - "[magenta]Testing Tor SOCKS connectivity...[/magenta]" - ): - result = ( - check_tor_connection() - ) - - if result.get( - "tor_working" - ): - console.print( - Panel( - f"[green]Tor connection operational[/green]\n" - f"SOCKS: " - f"{TOR_SOCKS_HOST}:" - f"{TOR_SOCKS_PORT}\n" - f"Exit IP: " - f"[cyan]" - f"{result.get('exit_ip')}" - f"[/cyan]", - title="Tor", - border_style="green", - ) - ) - else: - console.print( - Panel( - f"[red]Tor unavailable[/red]\n" - f"{result.get('error')}", - title="Tor", - border_style="red", - ) - ) - - # --------------------------------------------------------- - # JSON report - # --------------------------------------------------------- - - elif choice == "11": - target = _prompt( - "Target" - ) - - if not target: - continue - - with console.status( - "[cyan]Building report...[/cyan]" - ): - report = full_recon( - target - ) - - _save_report_interactive( - report - ) - - # --------------------------------------------------------- - # Exit - # --------------------------------------------------------- - - elif choice == "12": - console.print( - "[dim]Leaving Dark Web Intelligence module.[/dim]" - ) - break - - except KeyboardInterrupt: - console.print( - "\n[yellow]Operation cancelled.[/yellow]" - ) - - except Exception as exc: - console.print( - f"[red]Error: " - f"{type(exc).__name__}: " - f"{exc}[/red]" - ) - - -# --------------------------------------------------------------------- -# Backwards compatibility -# --------------------------------------------------------------------- - - -def darkweb_menu() -> None: - return run_darkweb_menu() - - -# --------------------------------------------------------------------- -# Module entry point -# --------------------------------------------------------------------- - - - -# ============== Configuration ============== -COMMON_PORTS = { - 21: "FTP", - 22: "SSH", - 23: "Telnet", - 25: "SMTP", - 53: "DNS", - 80: "HTTP", - 110: "POP3", - 143: "IMAP", - 443: "HTTPS", - 445: "SMB", - 993: "IMAPS", - 995: "POP3S", - 1433: "MSSQL", - 1521: "Oracle", - 3306: "MySQL", - 3389: "RDP", - 5432: "PostgreSQL", - 5900: "VNC", - 6379: "Redis", - 8080: "HTTP-Alt", - 8443: "HTTPS-Alt", - 27017: "MongoDB", -} - -PORT_DESCRIPTIONS = { - 21: "File Transfer Protocol", - 22: "Secure Shell", - 23: "Telnet", - 25: "Mail Server", - 53: "Domain Name System", - 80: "Web Server", - 110: "Mail Retrieval", - 143: "Mail Access", - 443: "Secure Web", - 445: "SMB/CIFS", - 993: "Secure IMAP", - 995: "Secure POP", - 1433: "MS SQL Server", - 1521: "Oracle DB", - 3306: "MySQL Server", - 3389: "Remote Desktop", - 5432: "PostgreSQL", - 5900: "VNC", - 6379: "Redis", - 8080: "Alt HTTP", - 8443: "Alt HTTPS", - 27017: "MongoDB", -} - -VULNERABILITIES = { - 21: ["Anonymous FTP enabled", "FTP cleartext transmission"], - 22: ["SSH brute-force possible", "Outdated SSH version"], - 23: ["Telnet unencrypted", "No authentication required"], - 25: ["Open SMTP relay", "Mail server misconfiguration"], - 80: ["HTTP methods enabled", "Directory listing possible"], - 443: ["SSL/TLS vulnerabilities", "Outdated certificates"], - 445: ["SMB vulnerabilities", "EternalBlue (MS17-010)"], - 3306: ["MySQL exposed", "Default credentials possible"], - 3389: ["RDP vulnerabilities", "BlueKeep (CVE-2019-0708)"], - 8080: ["Proxy misconfiguration", "Debug endpoints exposed"], -} - -TIMING_PROFILES = { - "T0": {"threads": 1, "timeout": 5.0, "delay": 300, "name": "Paranoid"}, - "T1": {"threads": 5, "timeout": 3.0, "delay": 100, "name": "Sneaky"}, - "T2": {"threads": 10, "timeout": 2.0, "delay": 50, "name": "Polite"}, - "T3": {"threads": 50, "timeout": 1.0, "delay": 10, "name": "Normal"}, - "T4": {"threads": 100, "timeout": 0.5, "delay": 0, "name": "Aggressive"}, - "T5": {"threads": 200, "timeout": 0.2, "delay": 0, "name": "Insane"}, -} - -COMMON_SUBDOMAINS = [ - "www", "mail", "ftp", "admin", "blog", "dev", "test", "api", "cdn", - "smtp", "pop", "imap", "ssh", "vpn", "cloud", "backup", "staging", - "demo", "old", "new", -] - -HTTP_PATHS = [ - "/", "/admin", "/login", "/wp-admin", "/phpmyadmin", "/api", "/api-docs", - "/swagger", "/console", "/manager", "/backup", "/wp-login.php", - "/admin/login", "/administrator", "/.env", "/config", "/config.php", - "/settings", "/dashboard", "/robots.txt", "/sitemap.xml", "/.git/config", - "/.htaccess", -] - -# Colors -GREEN = "\033[92m" -RED = "\033[91m" -YELLOW = "\033[93m" -CYAN = "\033[96m" -BLUE = "\033[94m" -MAGENTA = "\033[95m" -RESET = "\033[0m" -BOLD = "\033[1m" - -# ============== Report Manager ============== -class ReportManager: - def __init__(self): - self.report_dir = os.path.expanduser("~/.local/share/SpectraScan") - os.makedirs(self.report_dir, exist_ok=True) - self.current_file = None - self.append_file = False - - def save_output(self): - # In CLI mode, we might skip auto-prompting if not explicitly asked, - # but we keep the logic for consistency. - pass - - def write(self, text): - # In CLI mode, we can just print to console directly - print(text) - - def read_report(self): - report_files = [f for f in os.listdir(self.report_dir) if f.startswith('SS-report')] - if not report_files: - print(f"{RED}No reports found.{RESET}") - return - print(f"\n{CYAN}[-] Files in {self.report_dir}:{RESET}") - for i, f in enumerate(report_files): - print(f"{GREEN}[{i+1}]{RESET} {f}") - - try: - target_num = int(input(f"{CYAN}Enter number to read: {RESET}")) - if 1 <= target_num <= len(report_files): - file_path = os.path.join(self.report_dir, report_files[target_num-1]) - print(f"\n{BOLD}{RED}{file_path}{RESET}\n") - with open(file_path, 'r') as f: - print(f.read()) - else: - print(f"{RED}Invalid input.{RESET}") - except ValueError: - print(f"{RED}Invalid input.{RESET}") - - def delete_report(self): - report_files = [f for f in os.listdir(self.report_dir) if f.startswith('SS-report')] - if not report_files: - print(f"{RED}No reports found.{RESET}") - return - print(f"\n{CYAN}[-] Files in {self.report_dir}:{RESET}") - for i, f in enumerate(report_files): - print(f"{GREEN}[{i+1}]{RESET} {f}") - - try: - target_num = int(input(f"{CYAN}Enter number to delete: {RESET}")) - if 1 <= target_num <= len(report_files): - file_path = os.path.join(self.report_dir, report_files[target_num-1]) - confirm = input(f"{RED}Delete {file_path}? (y/N): {RESET}").lower() - if confirm == 'y': - os.remove(file_path) - print(f"{GREEN}[+] Deleted.{RESET}") - else: - print(f"{YELLOW}Cancelled.{RESET}") - else: - print(f"{RED}Invalid input.{RESET}") - except ValueError: - print(f"{RED}Invalid input.{RESET}") - -# ============== Utility Functions ============== -def normalize_target(target: str) -> str: - """Normalize an IP, CIDR, hostname, host:port, or HTTP(S) URL.""" - if not target: - return "" - - target = target.strip() - target = target.strip("\"'") - - if not target: - return "" - - try: - ipaddress.ip_network(target, strict=False) - return target - except ValueError: - pass - - if "://" in target: - try: - parsed = urllib.parse.urlparse(target) - if parsed.hostname: - return parsed.hostname - except Exception: - pass - - try: - if target.startswith("["): - closing = target.find("]") - if closing != -1: - return target[1:closing] - - parsed = urllib.parse.urlparse("//" + target) - if parsed.hostname: - return parsed.hostname - except Exception: - pass - - return target.rstrip("/") - - -def resolve_host(hostname: str) -> str: - """Resolve a single host/IP. CIDR networks are handled separately.""" - target = normalize_target(hostname) - - if not target: - raise ValueError("Target cannot be empty") - - try: - network = ipaddress.ip_network(target, strict=False) - raise ValueError(f"CIDR network detected: {network}") - except ValueError as exc: - if "CIDR network detected" in str(exc): - raise - - try: - return socket.gethostbyname(target) - except socket.gaierror as exc: - raise ValueError(f"Cannot resolve target: {target}") from exc - -def reverse_dns(ip: str) -> str: - """Reverse DNS lookup""" - try: - return socket.gethostbyaddr(ip) - except: - return "Unknown" - -def get_local_ip() -> str: - """Get local IP address""" - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname() - s.close() - return local_ip - except: - return "127.0.0.1" - -def generate_decoys(count: int) -> list: - """Generate random decoy IP addresses""" - decoys = [] - for _ in range(count): - ip_type = random.randint(1, 3) - if ip_type == 1: - decoy = f"10.{random.randint(1,255)}.{random.randint(1,255)}.{random.randint(1,255)}" - elif ip_type == 2: - decoy = f"172.{random.randint(16,31)}.{random.randint(1,255)}.{random.randint(1,255)}" - else: - decoy = f"192.168.{random.randint(1,255)}.{random.randint(1,255)}" - decoys.append(decoy) - return decoys - -def grab_banner(ip: str, port: int, timeout: float = 3.0) -> str: - """Grab banner from service""" - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(timeout) - sock.connect((ip, port)) - probes = { - 22: b"SSH-2.0-OpenSSH_8.0\r\n", - 80: b"HEAD / HTTP/1.0\r\n\r\n", - 443: b"HEAD / HTTP/1.0\r\n\r\n", - } - if port in probes: - sock.send(probes[port]) - banner = sock.recv(1024).decode("utf-8", errors="ignore").strip() - sock.close() - return banner if banner else "" - except: - return "" - -def identify_service(banner: str) -> Dict: - """Identify service from banner""" - if not banner: - return {"type": "unknown", "version": "unknown"} - signatures = { - "SSH": ["SSH", "OpenSSH", "Dropbear"], - "FTP": ["220", "FTP", "vsftpd", "ProFTPD"], - "HTTP": ["HTTP", "Apache", "nginx", "IIS"], - "MySQL": ["mysql", "MariaDB"], - "Redis": ["REDIS", "-ERR"], - } - for service, sigs in signatures.items(): - for sig in sigs: - if sig.lower() in banner.lower(): - ver = re.search(r"(\d+\.\d+(?:\.\d+)?)", banner) - return {"type": service, "version": ver.group(1) if ver else "unknown"} - return {"type": "unknown", "version": "unknown"} - -def check_vulnerabilities(port: int, service: str, banner: str = "") -> List[Dict]: - """Check for known vulnerabilities""" - vulns = [] - if port in VULNERABILITIES: - for vuln in VULNERABILITIES[port]: - severity = ( - "HIGH" - if any(k in vuln.lower() for k in ["unencrypted", "exposed", "vuln"]) - else "MEDIUM" - ) - vulns.append( - { - "port": port, - "service": service, - "vulnerability": vuln, - "severity": severity, - "cve": "N/A", - } - ) - return vulns - -# ============== Rate Limiter ============== -class RateLimiter: - __slots__ = ("pps", "delay", "last_request") - - def __init__(self, packets_per_second: int = 100): - self.pps = packets_per_second - self.delay = 1.0 / packets_per_second if packets_per_second > 0 else 0 - self.last_request = 0 - - def wait(self): - if self.delay > 0: - elapsed = time.time() - self.last_request - if elapsed < self.delay: - time.sleep(self.delay - elapsed) - self.last_request = time.time() - -# ============== SYN Scanner ============== -class SYNScan: - __slots__ = ("is_admin",) - - def __init__(self): - self.is_admin = os.geteuid() == 0 if hasattr(os, "geteuid") else False - - def create_packet(self, src_ip: str, dst_ip: str, dst_port: int) -> bytes: - ip_header = struct.pack( - "!BBHHHBBH4s4s", - 0x45, - 0, - 40, - random.randint(1, 65535), - 0, - 64, - 6, - 0, - socket.inet_aton(src_ip), - socket.inet_aton(dst_ip), - ) - tcp_header = struct.pack( - "!HHLLBBHHH", - random.randint(1024, 65535), - dst_port, - random.randint(0, 4294967295), - 0, - 0x50, - 0x02, - 65535, - 0, - 0, - ) - return ip_header + tcp_header - - def scan(self, target_ip: str, port: int, timeout: float = 2.0) -> str: - # Fall back to TCP connect scan if we don't have raw socket privileges - if not self.is_admin: - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(timeout) - rc = sock.connect_ex((target_ip, port)) - sock.close() - if rc == 0: - return "open" - elif rc in (111, 10061): # ECONNREFUSED - return "closed" - else: - return "filtered" - except socket.timeout: - return "filtered" - except: - return "error" - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) - sock.settimeout(timeout) - packet = self.create_packet(get_local_ip(), target_ip, port) - sock.sendto(packet, (target_ip, 0)) - data, _ = sock.recvfrom(1024) - sock.close() - tcp_flags = data[20 + 13] - if tcp_flags & 0x12: - return "open" - elif tcp_flags & 0x14 or tcp_flags & 0x04: - return "closed" - return "filtered" - except: - return "error" - -# ============== UDP Scanner ============== -class UDPScan: - __slots__ = ("common_udp_ports",) - - def __init__(self): - self.common_udp_ports = { - 53: ("DNS", "DNS Query"), - 67: ("DHCP", "DHCP Server"), - 68: ("DHCP", "DHCP Client"), - 69: ("TFTP", "Trivial FTP"), - 123: ("NTP", "Network Time Protocol"), - 161: ("SNMP", "Simple Network Management"), - 162: ("SNMPTRAP", "SNMP Trap"), - 389: ("LDAP", "Lightweight Directory Access"), - 500: ("ISAKMP", "Internet Security"), - 514: ("Syslog", "System Logging"), - 520: ("RIP", "Routing Information"), - 1194: ("OpenVPN", "VPN"), - 1701: ("L2TP", "Layer 2 Tunneling"), - 1812: ("RADIUS", "Authentication"), - 1813: ("RADIUS", "Accounting"), - 4500: ("IPSec", "NAT Traversal"), - } - - def scan(self, target_ip: str, port: int, timeout: float = 3.0) -> Dict: - svc_tuple = self.common_udp_ports.get(port, ("unknown", "")) - result = { - "port": port, - "protocol": "udp", - "state": "open|filtered", - "service": svc_tuple, - "info": svc_tuple, - } - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.settimeout(timeout) - probes = { - 53: b"\x00\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x06google\x03com\x00\x00\x01\x00\x01", - 161: b"\x30\x00\x00\x00\x02\x01\x00\x04\x06public\xa0\x1f\x02\x01\x00\x02\x01\x00\x30\x14", - } - sock.sendto(probes.get(port, b"\x00"), (target_ip, port)) - try: - data, _ = sock.recvfrom(1024) - result["state"] = "open" - result["response"] = data.hex()[:100] - except socket.timeout: - result["state"] = "open|filtered" - sock.close() - except socket.error as e: - result["state"] = "error" - result["error"] = str(e) - return result - -# ============== OS Fingerprinting ============== -class OSFingerprint: - @staticmethod - def get_ttl(ip: str) -> Optional[int]: - try: - cmd = ( - ["ping", "-n", "1", "-w", "100", ip] - if sys.platform == "win32" - else ["ping", "-c", "1", "-W", "2", ip] - ) - output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode() - match = re.search(r"ttl=(\d+)", output, re.IGNORECASE) - return int(match.group(1)) if match else None - except: - return None - - @staticmethod - def detect_os(ip: str) -> Dict: - result = {"os": "Unknown", "confidence": 0, "ttl": None, "details": []} - ttl = OSFingerprint.get_ttl(ip) - if ttl: - result["ttl"] = ttl - result["details"].append(f"TTL: {ttl}") - if ttl <= 64: - result["os"] = "Linux/Unix/MacOS" - result["confidence"] = 70 - elif ttl <= 128: - result["os"] = "Windows" - result["confidence"] = 70 - else: - result["os"] = "Network Device/Unix" - result["confidence"] = 50 - try: - start = time.time() - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(3) - sock.connect((ip, 80)) - sock.close() - response_time = time.time() - start - result["details"].append(f"Response: {response_time*1000:.1f}ms") - result["details"].append( - "Fast response: Likely Linux/Unix" - if response_time < 0.1 - else "Slow response: Possibly Windows" - ) - except: - pass - return result - -# ============== SSL/TLS Analyzer ============== -class SSLAnalyzer: - @staticmethod - def analyze(ip: str, port: int = 443, timeout: float = 5.0) -> Dict: - result = { - "supports_ssl": False, - "versions": [], - "cipher_suites": [], - "certificate": {}, - "vulnerabilities": [], - "grade": "N/A", - } - try: - context = ssl.create_default_context() - context.check_hostname = False - context.verify_mode = ssl.CERT_NONE - with socket.create_connection((ip, port), timeout=timeout) as sock: - with context.wrap_socket(sock, server_hostname=ip) as ssock: - result["supports_ssl"] = True - result["versions"] = ssock.version() - if ssock.cipher(): - result["certificate"] = {"cipher": ssock.cipher()} - version = ssock.version() - if version in ["SSLv2", "SSLv3"]: - result["vulnerabilities"].append( - f"Deprecated {version} protocol" - ) - result["grade"] = ( - "A+" - if "TLSv1.3" in str(result["versions"]) - else ( - "A" - if "TLSv1.2" in str(result["versions"]) - else "C" if "TLSv1.0" in str(result["versions"]) else "F" - ) - ) - except: - pass - return result - -# ============== HTTP Enumerator ============== -class HTTPEnumerator: - @staticmethod - def get_headers(ip: str, port: int = 80, use_ssl: bool = False) -> Dict: - result = { - "server": "Unknown", - "content_type": None, - "security_headers": {}, - "methods": [], - "status_code": None, - } - protocol = "https" if use_ssl else "http" - url = f"{protocol}://{ip}:{port}/" - try: - req = Request(url, method="GET") - req.add_header("User-Agent", "Mozilla/5.0 (Port Scanner)") - with urlopen(req, timeout=5) as response: - result["status_code"] = response.status - result["server"] = response.headers.get("Server", "Unknown") - result["content_type"] = response.headers.get("Content-Type") - result["security_headers"] = { - "X-Frame-Options": response.headers.get("X-Frame-Options"), - "X-Content-Type": response.headers.get("X-Content-Type-Options"), - "CSP": response.headers.get("Content-Security-Policy"), - "HSTS": response.headers.get("Strict-Transport-Security"), - } - except: - pass - return result - - @staticmethod - def check_methods(ip: str, port: int = 80, use_ssl: bool = False) -> List[str]: - methods = [] - protocol = "https" if use_ssl else "http" - url = f"{protocol}://{ip}:{port}/" - http_methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"] - for method in http_methods: - try: - req = Request(url, method=method) - req.add_header("User-Agent", "Port Scanner") - with urlopen(req, timeout=3) as response: - if response.status and response.status < 400: - methods.append(method) - except: - pass - return methods - - @staticmethod - def enumerate_paths(ip: str, port: int = 80, use_ssl: bool = False) -> List[Dict]: - found = [] - protocol = "https" if use_ssl else "http" - for path in HTTP_PATHS: - url = f"{protocol}://{ip}:{port}{path}" - try: - req = Request(url, method="GET") - req.add_header("User-Agent", "Port Scanner") - with urlopen(req, timeout=3) as response: - found.append( - { - "path": path, - "status": response.status, - "size": response.headers.get("Content-Length", "Unknown"), - } - ) - except HTTPError as e: - found.append({"path": path, "status": e.code, "size": 0}) - except: - pass - return found - -# ============== DNS Enumerator ============== -class DNSEnumerator: - @staticmethod - def get_records(domain: str) -> Dict: - result = {"A": [], "AAAA": [], "MX": [], "NS": [], "TXT": [], "CNAME": []} - try: - import dns.resolver - for rtype in ["A", "AAAA", "MX", "NS", "TXT", "CNAME"]: - try: - answers = dns.resolver.resolve(domain, rtype) - for rdata in answers: - if rtype == "MX": - result["MX"].append( - f"{rdata.exchange} (pref: {rdata.preference})" - ) - else: - result[rtype].append(str(rdata)) - except: - pass - except ImportError: - try: - ip = socket.gethostbyname(domain) - result["A"].append(ip) - except: - pass - except: - pass - return result - - @staticmethod - def enumerate_subdomains(domain: str, wordlist: List[str] = None) -> List[str]: - found = [] - subdomains = wordlist or COMMON_SUBDOMAINS - for sub in subdomains: - hostname = f"{sub}.{domain}" - try: - ip = socket.gethostbyname(hostname) - found.append({"subdomain": hostname, "ip": ip}) - except: - pass - return found - -# ============== Service Enumerator ============== -class ServiceEnumerator: - @staticmethod - def enumerate_ftp(ip: str, port: int = 21) -> Dict: - result = {"anonymous": False, "features": [], "version": None} - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5) - sock.connect((ip, port)) - banner = sock.recv(1024).decode("utf-8", errors="ignore").strip() - result["version"] = banner - sock.send(b"USER anonymous\r\n") - resp = sock.recv(1024).decode("utf-8", errors="ignore") - if "331" in resp: - sock.send(b"PASS anonymous@example.com\r\n") - resp = sock.recv(1024).decode("utf-8", errors="ignore") - if "230" in resp: - result["anonymous"] = True - sock.send(b"FEAT\r\n") - resp = sock.recv(4096).decode("utf-8", errors="ignore") - if "211" in resp: - result["features"] = resp.split("\n")[1:-1] - sock.close() - except Exception as e: - result["error"] = str(e) - return result - - @staticmethod - def enumerate_ssh(ip: str, port: int = 22) -> Dict: - result = {"version": None, "algorithms": {}} - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5) - sock.connect((ip, port)) - banner = sock.recv(256).decode("utf-8", errors="ignore").strip() - result["version"] = banner - if "OpenSSH" in banner: - match = re.search(r"OpenSSH_([\d.]+)", banner) - if match: - result["version"] = f"OpenSSH {match.group(1)}" - sock.close() - except Exception as e: - result["error"] = str(e) - return result - - @staticmethod - def enumerate_smb(ip: str, port: int = 445) -> Dict: - result = {"version": None, "signing": None, "shares": []} - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5) - sock.connect((ip, port)) - sock.close() - except: - pass - return result - -# ============== Firewall Detector ============== -class FirewallDetector: - @staticmethod - def detect(ip: str, port: int = 80) -> Dict: - result = {"has_firewall": "Unknown", "evidence": [], "stealth_score": 0} - try: - if sys.platform == "win32": - cmd = ["ping", "-n", "1", "-w", "100", ip] - else: - cmd = ["ping", "-c", "1", "-W", "1", ip] - output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode() - if "TTL=" in output or "ttl=" in output: - result["evidence"].append("Host responds to ICMP") - else: - result["evidence"].append("No ICMP response - possible firewall") - result["stealth_score"] += 30 - except: - result["evidence"].append("ICMP blocked - likely firewall") - result["stealth_score"] += 40 - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(3) - sock.connect((ip, port)) - sock.close() - result["evidence"].append("TCP connection successful") - except: - result["evidence"].append("TCP connection blocked") - result["stealth_score"] += 20 - if result["stealth_score"] >= 50: - result["has_firewall"] = "Likely" - elif result["stealth_score"] >= 30: - result["has_firewall"] = "Possible" - else: - result["has_firewall"] = "Unlikely" - return result - -# ============== Ping Sweep ============== -class PingSweep: - @staticmethod - def sweep(network: str, timeout: float = 1.0) -> List[Dict]: - hosts = [] - try: - net = ipaddress.ip_network(network, strict=False) - print(f"{CYAN}[*] Scanning {network} for live hosts...") - def ping_host(ip): - try: - cmd = ( - ["ping", "-n", "1", "-w", str(int(timeout * 1000)), str(ip)] - if sys.platform == "win32" - else ["ping", "-c", "1", "-W", str(int(timeout)), str(ip)] - ) - result = subprocess.run( - cmd, capture_output=True, timeout=timeout + 1 - ) - if result.returncode == 0: - return {"ip": str(ip), "alive": True} - except: - pass - return {"ip": str(ip), "alive": False} - with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor: - futures = [executor.submit(ping_host, ip) for ip in net.hosts()] - for future in concurrent.futures.as_completed(futures): - res = future.result() - if res["alive"]: - hosts.append(res) - print(f"{GREEN}[+] Host found: {res['ip']}{RESET}") - except Exception as e: - print(f"{RED}[-] Error: {e}{RESET}") - return hosts - -# ============== ARP Scanner ============== -class ARPScanner: - @staticmethod - def scan(network: str) -> List[Dict]: - hosts = [] - try: - # Determine the network prefix (e.g., "192.168.1.") for membership testing - try: - net = ipaddress.ip_network(network, strict=False) - net_hosts = {str(ip) for ip in net.hosts()} - except ValueError: - net_hosts = None - - cmd = ["arp", "-a"] if sys.platform == "win32" else ["arp", "-a", "-n"] - output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode() - for line in output.split("\n"): - match = re.search( - r"(\d+\.\d+\.\d+\.\d+)\s+([0-9a-f:-]+)\s+(\w+)", - line, - re.IGNORECASE, - ) - if match: - ip, mac, iface = match.groups() - if net_hosts is None: - if ip.startswith(".".join(network.split(".")[:3]) + "."): - hosts.append({"ip": ip, "mac": mac, "interface": iface}) - else: - if ip in net_hosts: - hosts.append({"ip": ip, "mac": mac, "interface": iface}) - except Exception as e: - print(f"{RED}[-] ARP scan error: {e}{RESET}") - return hosts - -# ============== Traceroute ============== -def traceroute(target: str, max_hops: int = 30) -> List[Dict]: - hops = [] - print(f"{CYAN}[*] Traceroute to {target}...") - for ttl in range(1, max_hops + 1): - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.settimeout(2.0) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, ttl) - sock.sendto(b"traceroute", (target, 33434 + ttl)) - data, addr = sock.recvfrom(512) - hop_ip = addr if isinstance(addr, tuple) else addr - hops.append({"ttl": ttl, "ip": hop_ip, "hostname": reverse_dns(hop_ip)}) - sock.close() - if hop_ip == target: - break - except socket.timeout: - hops.append({"ttl": ttl, "ip": "*", "hostname": "Timeout"}) - except: - break - return hops - -# ============== Main Port Scanner ============== -class PortScanner: - __slots__ = ( - "target", - "timeout", - "threads", - "scan_type", - "timing", - "ports", - "decoys", - "check_vulns", - "rate_limit", - "resolved_ip", - "results", - "vulnerabilities", - "start_time", - "rate_limiter", - ) - - def __init__(self, target: str, **kwargs): - self.target = target - self.timeout = kwargs.get("timeout", 1.0) - self.threads = kwargs.get("threads", 100) - self.scan_type = kwargs.get("scan_type", "tcp") - self.timing = kwargs.get("timing", "T3") - self.ports = kwargs.get("ports", list(COMMON_PORTS.keys())) - self.decoys = kwargs.get("decoys", []) - self.check_vulns = kwargs.get("check_vulns", False) - self.rate_limit = kwargs.get("rate_limit", 0) - self.resolved_ip = None - self.results = [] - self.vulnerabilities = [] - self.start_time = None - self.rate_limiter = RateLimiter(self.rate_limit) if self.rate_limit else None - if self.timing in TIMING_PROFILES: - profile = TIMING_PROFILES[self.timing] - self.threads = profile["threads"] - self.timeout = profile["timeout"] - - def initialize(self): - print(f"\n{BOLD}{CYAN}{'='*60}") - print(f"{BOLD}{CYAN}[*] SpectraScan - Enhanced Edition") - print(f"{CYAN}{'='*60}{RESET}") - self.resolved_ip = resolve_host(self.target) - hostname = reverse_dns(self.resolved_ip) - print(f"{CYAN}[*] Target: {self.target} ({self.resolved_ip})") - print(f"[*] Hostname: {hostname}") - print(f"[*] Scan Type: {self.scan_type.upper()}") - print(f"[*] Ports: {len(self.ports)}") - print(f"[*] Threads: {self.threads}") - print(f"[*] Timing: {self.timing} ({TIMING_PROFILES[self.timing]['name']})") - if self.decoys: - print(f"{YELLOW}[*] Decoys: {len(self.decoys)} IPs active") - if self.rate_limit: - print(f"{YELLOW}[*] Rate Limit: {self.rate_limit} pps") - print(f"{CYAN}{'='*60}{RESET}\n") - - def scan_port(self, port: int) -> Dict: - if self.rate_limiter: - self.rate_limiter.wait() - result = { - "port": port, - "protocol": "tcp", - "state": "closed", - "service": COMMON_PORTS.get(port, "unknown"), - "description": PORT_DESCRIPTIONS.get(port, ""), - "banner": "", - "version_info": {"type": "unknown", "version": "unknown"}, - "vulnerabilities": [], - } - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(self.timeout) - if self.decoys: - decoy = random.choice(self.decoys) - try: - sock.bind((decoy, 0)) - except: - pass - if sock.connect_ex((self.resolved_ip, port)) == 0: - result["state"] = "open" - banner = grab_banner(self.resolved_ip, port, self.timeout) - result["banner"] = banner - result["version_info"] = identify_service(banner) - if self.check_vulns: - vulns = check_vulnerabilities(port, result["service"], banner) - if vulns: - result["vulnerabilities"] = vulns - self.vulnerabilities.extend(vulns) - sock.close() - except socket.timeout: - result["state"] = "filtered" - except socket.error: - result["state"] = "error" - return result - - def scan(self) -> List[Dict]: - self.start_time = time.time() - print(f"{CYAN}[*] Spectra is scanning...\n") - with concurrent.futures.ThreadPoolExecutor( - max_workers=self.threads - ) as executor: - futures = { - executor.submit(self.scan_port, port): port for port in self.ports - } - for future in concurrent.futures.as_completed(futures): - result = future.result() - if result["state"] == "open": - self.results.append(result) - self._print_result(result) - return self.results - - def _print_result(self, result: Dict): - vuln_count = len(result.get("vulnerabilities", [])) - vuln_str = f" {RED}[!]{vuln_count} vulns{RESET}" if vuln_count else "" - banner = result.get("banner", "") or "" - banner_display = banner[:50] if len(banner) > 50 else banner - - # Handle service being either a tuple or string - service = result.get("service", "unknown") - if isinstance(service, tuple): - service = service if service else "unknown" - service_display = service or "unknown" - - # Description fallback chain: description field -> PORT_DESCRIPTIONS -> service - description = result.get("description", "") or PORT_DESCRIPTIONS.get( - result.get("port"), service_display - ) - if isinstance(description, tuple): - description_display = description if description else "" - else: - description_display = description - - print( - f"{GREEN}[+] Port {result['port']:>5}/tcp " - f"{result['state']:<10} {service_display:<12} " - f"{CYAN}| {banner_display}{vuln_str}{RESET}" - ) - - def print_summary(self): - duration = time.time() - self.start_time if self.start_time else 0.0 - print(f"\n{CYAN}{'='*60}") - print(f"{GREEN}[✓] Scan completed in {duration:.2f} seconds") - print(f"[+] Found {len(self.results)} open ports") - if self.results: - print(f"\n{YELLOW}Open Ports Summary:{RESET}") - print(f"{'Port':<10}{'Service':<15}{'State':<10}{'Version'}") - print(f"{'-'*45}") - for r in sorted(self.results, key=lambda x: x["port"]): - service = r.get("service", "unknown") - if isinstance(service, tuple): - service = service if service else "unknown" - version = r.get("version_info", {}).get("version", "N/A") - print(f"{r['port']:<10}{service:<15}{r['state']:<10}{version}") - if self.vulnerabilities: - print(f"\n{RED}[!] Found {len(self.vulnerabilities)} vulnerabilities:{RESET}") - for v in self.vulnerabilities: - svc = v.get("service", "unknown") - if isinstance(svc, tuple): - svc = svc if svc else "unknown" - sev_color = RED if v.get("severity") == "HIGH" else YELLOW - print( - f" {RED}•{RESET} Port {v['port']} ({svc}): " - f"{v['vulnerability']} [{sev_color}{v.get('severity', 'INFO')}{RESET}]" - ) - print(f"{CYAN}{'='*60}{RESET}") - - def get_results(self) -> Dict: - return { - "target": self.target, - "resolved_ip": self.resolved_ip, - "scan_type": self.scan_type, - "timestamp": datetime.now().isoformat(), - "duration": (time.time() - self.start_time) if self.start_time else 0.0, - "open_ports": self.results, - "vulnerabilities": self.vulnerabilities, - } - - def export_json(self, filename: str): - with open(filename, "w") as f: - json.dump(self.get_results(), f, indent=2, default=str) - print(f"{GREEN}[+] JSON report saved: {filename}") - - def export_html(self, filename: str): - results = self.get_results() - target = html.escape(str(results.get("target", ""))) - scan_type = html.escape(str(results.get("scan_type", ""))) - resolved_ip = html.escape(str(results.get("resolved_ip", "") or "")) - timestamp = html.escape(str(results.get("timestamp", ""))) - duration = f"{results.get('duration', 0):.2f}" - - parts = [f""" - - - - SpectraScan Report - {target} - - - -
-

SpectraScan Report

-
-

Target: {target}

-

Resolved IP: {resolved_ip}

-

Scan Type: {scan_type}

-

Timestamp: {timestamp}

-

Duration: {duration}s

-

Open Ports: {len(results.get('open_ports', []))}

-

Vulnerabilities: {len(results.get('vulnerabilities', []))}

-
-

Open Ports

- - """] - - for port in results.get("open_ports", []): - port_num = html.escape(str(port.get("port", ""))) - service = port.get("service", "") - if isinstance(service, tuple): - service = service if service else "" - service = html.escape(str(service)) - state = html.escape(str(port.get("state", ""))) - banner = html.escape((port.get("banner", "") or "N/A")[:50]) - parts.append( - f"" - f"" - ) - - parts.append("""
PortServiceStateBanner
{port_num}{service}{state}{banner}
-
- -""") - - with open(filename, "w") as f: - f.write("".join(parts)) - print(f"{GREEN}[+] HTML report saved: {filename}") - - def export_csv(self, filename: str): - with open(filename, "w", newline="") as f: - writer = csv.writer(f) - writer.writerow( - ["Port", "Protocol", "State", "Service", "Banner", "Vulnerabilities"] - ) - for port in self.results: - service = port.get("service", "") - if isinstance(service, tuple): - service = service if service else "" - banner = port.get("banner", "") or "None" - vulns = ( - "; ".join( - [v["vulnerability"] for v in port.get("vulnerabilities", [])] - ) - or "None" - ) - writer.writerow( - [ - port["port"], - "tcp", - port["state"], - service, - banner[:50], - vulns, - ] - ) - print(f"{GREEN}[+] CSV report saved: {filename}") - -# ============== Network Scanner ============== -class NetworkScanner: - @staticmethod - def scan_network(network: str, ports: List[int] = None, **kwargs) -> List[Dict]: - try: - net = ipaddress.ip_network(network, strict=False) - print(f"{CYAN}[*] Spectra is Scanning network: {network}") - print(f"[*] Hosts: {net.num_addresses - 2}") - results = [] - for ip in net.hosts(): - ip_str = str(ip) - scanner = PortScanner( - ip_str, ports=ports or list(COMMON_PORTS.keys()), **kwargs - ) - scanner.resolved_ip = ip_str - scanner.scan() - if scanner.results: - results.append( - { - "ip": ip_str, - "hostname": reverse_dns(ip_str), - "ports": scanner.results, - } - ) - print(f"{GREEN}[+] {ip_str}: {len(scanner.results)} open ports") - return results - except ValueError as e: - print(f"{RED}[-] Invalid network: {e}") - return [] - -# ============== Protocol Module Scanner ============== -class ProtocolModuleScanner: - """Stub implementation of protocol scanners. - Replace these methods with calls into the real modules in ./modules if present. - """ - - @staticmethod - def _print_result(name: str, result: Dict): - console.print(f"\n[bold cyan]--- {name} ---[/bold cyan]") - if isinstance(result, dict): - for key, value in result.items(): - console.print(f" [green]{key}:[/green] {value}") - else: - console.print(f" {result}") - - @staticmethod - def _tcp_probe(ip: str, port: int, timeout: float = 5.0) -> Dict: - result = {"ip": ip, "port": port, "state": "closed", "banner": None} - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(timeout) - if sock.connect_ex((ip, port)) == 0: - result["state"] = "open" - try: - sock.settimeout(2.0) - data = sock.recv(256) - if data: - result["banner"] = data.decode("utf-8", errors="ignore").strip() - except: - pass - sock.close() - except socket.timeout: - result["state"] = "filtered" - except Exception as e: - result["state"] = "error" - result["error"] = str(e) - return result - - @staticmethod - def run_smb(target: str): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - result = ProtocolModuleScanner._tcp_probe(ip, 445) - ProtocolModuleScanner._print_result("SMB Enumerator", result) - return result - - @staticmethod - def run_snmp(target: str): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - result = ProtocolModuleScanner._tcp_probe(ip, 161, timeout=3.0) - # SNMP is UDP, so do a quick UDP probe too - try: - usock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - usock.settimeout(3.0) - probe = b"\x30\x00\x00\x00\x02\x01\x00\x04\x06public\xa0\x1f\x02\x01\x00\x02\x01\x00\x30\x14" - usock.sendto(probe, (ip, 161)) - data, _ = usock.recvfrom(1024) - result["snmp_response"] = data.hex()[:100] - result["state"] = "open" - usock.close() - except socket.timeout: - result["snmp_response"] = None - except Exception as e: - result["snmp_error"] = str(e) - ProtocolModuleScanner._print_result("SNMP Enumerator", result) - return result - - @staticmethod - def run_ldap(target: str, port: int = 389): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - result = ProtocolModuleScanner._tcp_probe(ip, port) - ProtocolModuleScanner._print_result("LDAP Enumerator", result) - return result - - @staticmethod - def run_rdp(target: str): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - result = ProtocolModuleScanner._tcp_probe(ip, 3389) - ProtocolModuleScanner._print_result("RDP Enumerator", result) - return result - - @staticmethod - def run_smtp(target: str, port: int = 25, test_relay: bool = True): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - result = {"banner": None, "open_relay": None, "error": None} - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5) - sock.connect((ip, port)) - banner = sock.recv(1024).decode("utf-8", errors="ignore").strip() - result["banner"] = banner - if test_relay: - sock.send(b"EHLO test.local\r\n") - sock.recv(1024) - sock.send(b"MAIL FROM:\r\n") - resp = sock.recv(1024).decode("utf-8", errors="ignore") - sock.send(b"RCPT TO:\r\n") - resp2 = sock.recv(1024).decode("utf-8", errors="ignore") - result["open_relay"] = "250" in resp2 - sock.send(b"QUIT\r\n") - sock.close() - except Exception as e: - result["error"] = str(e) - ProtocolModuleScanner._print_result("SMTP Enumerator", result) - return result - - @staticmethod - def run_dns_zone(domain: str): - result = {"domain": domain, "records": {}} - try: - import dns.resolver - import dns.zone - import dns.query - ns_answers = dns.resolver.resolve(domain, "NS") - for ns in ns_answers: - ns_str = str(ns).rstrip(".") - try: - z = dns.zone.from_xfr(dns.query.xfr(ns_str, domain, timeout=5)) - result["records"][ns_str] = [str(n) for n in z.nodes.keys()] - except Exception as e: - result["records"][ns_str] = f"Transfer failed: {e}" - except ImportError: - result["records"]["error"] = "dnspython not installed" - except Exception as e: - result["records"]["error"] = str(e) - ProtocolModuleScanner._print_result("DNS Zone Transfer", result) - return result - - @staticmethod - def run_nfs(target: str): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - result = ProtocolModuleScanner._tcp_probe(ip, 2049) - ProtocolModuleScanner._print_result("NFS Enumerator", result) - return result - - @staticmethod - def run_vnc(target: str, port: int = 5900): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - result = ProtocolModuleScanner._tcp_probe(ip, port) - ProtocolModuleScanner._print_result("VNC Enumerator", result) - return result - - @staticmethod - def run_redis(target: str, port: int = 6379): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - result = {"ip": ip, "port": port} - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5) - sock.connect((ip, port)) - sock.send(b"PING\r\n") - data = sock.recv(1024).decode("utf-8", errors="ignore").strip() - result["response"] = data - result["unauth"] = "PONG" in data - sock.close() - except Exception as e: - result["error"] = str(e) - ProtocolModuleScanner._print_result("Redis Enumerator", result) - return result - - @staticmethod - def run_mongodb(target: str, port: int = 27017): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - result = {"ip": ip, "port": port} - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5) - sock.connect((ip, port)) - # Send a minimal ismaster command - sock.close() - result["state"] = "open" - except Exception as e: - result["error"] = str(e) - ProtocolModuleScanner._print_result("MongoDB Enumerator", result) - return result - - @staticmethod - def run_sip(target: str, port: int = 5060): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - result = {"ip": ip, "port": port, "state": "unknown"} - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.settimeout(3.0) - sock.sendto(b"OPTIONS sip:test@" + ip.encode() + b" SIP/2.0\r\n\r\n", (ip, port)) - data, _ = sock.recvfrom(1024) - result["state"] = "open" - result["response"] = data.decode("utf-8", errors="ignore").strip()[:200] - sock.close() - except socket.timeout: - result["state"] = "open|filtered" - except Exception as e: - result["error"] = str(e) - ProtocolModuleScanner._print_result("SIP Enumerator", result) - return result - - @staticmethod - def run_rtsp(target: str, port: int = 554): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - result = {"ip": ip, "port": port} - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(5) - sock.connect((ip, port)) - sock.send(b"OPTIONS * RTSP/1.0\r\nCSeq: 1\r\n\r\n") - data = sock.recv(1024).decode("utf-8", errors="ignore").strip() - result["response"] = data[:200] - result["state"] = "open" - sock.close() - except Exception as e: - result["error"] = str(e) - ProtocolModuleScanner._print_result("RTSP Enumerator", result) - return result - - @staticmethod - def run_databases(target: str): - ip = target if target.replace(".", "").isdigit() else resolve_host(target) - db_ports = { - "mysql": 3306, - "postgres": 5432, - "mssql": 1433, - "oracle": 1521, - } - results = {} - for db, port in db_ports.items(): - results[db] = ProtocolModuleScanner._tcp_probe(ip, port, timeout=3.0) - for db_name, db_result in results.items(): - ProtocolModuleScanner._print_result(db_name.upper(), db_result) - return results - - -def run_protocol_modules(): - """Interactive menu for protocol modules.""" - console.print("\n[bold cyan]--- PROTOCOL ENUMERATION MODULES ---[/bold cyan]") - console.print("[bold green]1.[/bold green] SMB Enumerator") - console.print("[bold green]2.[/bold green] SNMP Enumerator") - console.print("[bold green]3.[/bold green] LDAP Enumerator") - console.print("[bold green]4.[/bold green] RDP Enumerator") - console.print("[bold green]5.[/bold green] SMTP Enumerator") - console.print("[bold green]6.[/bold green] DNS Zone Transfer") - console.print("[bold green]7.[/bold green] NFS Enumerator") - console.print("[bold green]8.[/bold green] VNC Enumerator") - console.print("[bold green]9.[/bold green] Redis Enumerator") - console.print("[bold green]10.[/bold green] MongoDB Enumerator") - console.print("[bold green]11.[/bold green] SIP Enumerator") - console.print("[bold green]12.[/bold green] RTSP Enumerator") - console.print("[bold green]13.[/bold green] Database Scan (MySQL/Postgres/MSSQL)") - console.print("[bold blue]14.[/bold blue] Dark Web Recon") - console.print("[bold red]15.[/bold red] Back to main menu") - - choice = hacker_input("Select module") - - def _safe_int(prompt: str, default: int) -> int: - raw = hacker_input(prompt, str(default)) - try: - return int(raw) - except (TypeError, ValueError): - return default - - try: - if choice == "1": - target = hacker_input("Enter target IP/hostname") - if target: - ProtocolModuleScanner.run_smb(target) - elif choice == "2": - target = hacker_input("Enter target IP/hostname") - if target: - ProtocolModuleScanner.run_snmp(target) - elif choice == "3": - target = hacker_input("Enter target IP/hostname") - port = _safe_int("Port (default 389)", 389) - if target: - ProtocolModuleScanner.run_ldap(target, port) - elif choice == "4": - target = hacker_input("Enter target IP/hostname") - if target: - ProtocolModuleScanner.run_rdp(target) - elif choice == "5": - target = hacker_input("Enter target IP/hostname") - relay = Confirm.ask("Test open relay?", default=True) - if target: - ProtocolModuleScanner.run_smtp(target, test_relay=relay) - elif choice == "6": - domain = hacker_input("Enter domain (e.g., example.com)") - if domain: - ProtocolModuleScanner.run_dns_zone(domain) - elif choice == "7": - target = hacker_input("Enter target IP/hostname") - if target: - ProtocolModuleScanner.run_nfs(target) - elif choice == "8": - target = hacker_input("Enter target IP/hostname") - port = _safe_int("Port (default 5900)", 5900) - if target: - ProtocolModuleScanner.run_vnc(target, port) - elif choice == "9": - target = hacker_input("Enter target IP/hostname") - if target: - ProtocolModuleScanner.run_redis(target) - elif choice == "10": - target = hacker_input("Enter target IP/hostname") - if target: - ProtocolModuleScanner.run_mongodb(target) - elif choice == "11": - target = hacker_input("Enter target IP/hostname") - if target: - ProtocolModuleScanner.run_sip(target) - elif choice == "12": - target = hacker_input("Enter target IP/hostname") - if target: - ProtocolModuleScanner.run_rtsp(target) - elif choice == "13": - target = hacker_input("Enter target IP/hostname") - if target: - ProtocolModuleScanner.run_databases(target) - elif choice == "14": - # ---- Dark Web Recon ---- - try: - run_darkweb_menu() - except KeyboardInterrupt: - console.print( - "[*] Dark Web module interrupted.", - style="yellow") - except Exception as e: - console.print( - f"[!] Dark Web module error: {e}", - style="bold red") - elif choice == "15": - return - else: - console.print("[!] Invalid option.", style="red") - except Exception as e: - console.print(f"{RED}[!] Error: {e}{RESET}", style="red") - - -# ============== Domain Intelligence Scanner ============== -class DomainScanner: - """Passive-first domain intelligence for SpectraScan. - - Keeps the existing DomainScanner.scan(domain, report_manager) API while - adding WHOIS, DNS, SPF, DMARC, DKIM selector checks, CAA, DNSSEC evidence, - Certificate Transparency, passive subdomain discovery, HTTP/TLS metadata, - security headers, technology/CDN hints, robots.txt, sitemap and scoring. - """ - - VERSION = "2.0.0" - HTTP_TIMEOUT = 12 - DNS_TIMEOUT = 4 - CT_TIMEOUT = 20 - USER_AGENT = f"SpectraScan-DomainIntelligence/{VERSION}" - - DNS_TYPES = ("A", "AAAA", "MX", "NS", "TXT", "CNAME", "SOA", "CAA", "SRV") - SECURITY_HEADERS = ( - "strict-transport-security", "content-security-policy", - "x-content-type-options", "x-frame-options", "referrer-policy", - "permissions-policy", "cross-origin-opener-policy", - "cross-origin-resource-policy", - ) - DKIM_SELECTORS = ( - "default", "selector1", "selector2", "google", "k1", "k2", - "mail", "dkim", "s1", "s2", "smtp", "mandrill", "zoho", - "sendgrid", "mailjet", "amazonses", "protonmail", - ) - TECH_PATTERNS = { - "WordPress": (r"/wp-content/", r"/wp-includes/", r"wp-json"), - "Drupal": (r"drupalSettings", r"/sites/default/"), - "Joomla": (r"/media/system/", r"joomla"), - "Next.js": (r"__NEXT_DATA__", r"/_next/"), - "Nuxt": (r"__NUXT__", r"/_nuxt/"), - "React": (r"react(?:dom)?",), - "Vue.js": (r"vue(?:\.router)?",), - "Angular": (r"ng-version", r"angular"), - "jQuery": (r"jquery",), - "Bootstrap": (r"bootstrap",), - "Tailwind CSS": (r"tailwind",), - "Google Analytics": (r"google-analytics", r"gtag", r"googletagmanager"), - "Google Tag Manager": (r"googletagmanager", r"gtm\.js"), - "Cloudflare": (r"cf-ray", r"cloudflare"), - } - CDN_PATTERNS = { - "Cloudflare": ("cf-ray", "cf-cache-status", "cloudflare"), - "Akamai": ("akamai", "x-akamai"), - "Fastly": ("fastly", "x-served-by"), - "Amazon CloudFront": ("cloudfront", "x-amz-cf-id", "x-amz-cf-pop"), - "Imperva": ("imperva", "incap_ses", "visid_incap"), - "Azure Front Door": ("x-azure-ref", "azure"), - } - - @staticmethod - def normalize_domain(value: str) -> str: - value = (value or "").strip() - if not value: - return "" - if "://" not in value: - value = "https://" + value - try: - host = __import__("urllib.parse", fromlist=["urlparse"]).urlparse(value).hostname or "" - except Exception: - host = "" - return host.lower().rstrip(".") - - @staticmethod - def _unique(values): - seen, out = set(), [] - for value in values or []: - if value is None: - continue - value = str(value).strip().rstrip(".") - if value and value.lower() not in seen: - seen.add(value.lower()) - out.append(value) - return out - - @staticmethod - def _write(report_manager, text): - try: - report_manager.write(str(text)) - except Exception: - pass - - @classmethod - def _http(cls, url, timeout=None): - try: - import requests - return requests.get( - url, - headers={"User-Agent": cls.USER_AGENT, "Accept": "*/*"}, - timeout=timeout or cls.HTTP_TIMEOUT, - allow_redirects=True, - verify=True, - ) - except Exception: - return None - - @classmethod - def _whois(cls, domain): - result = {"available": False, "source": None, "registrar": None, - "organization": None, "creation_date": None, - "expiration_date": None, "updated_date": None, - "name_servers": [], "status": [], "emails": [], "raw": None} - try: - import whois - data = whois.whois(domain) - result["available"] = True - result["source"] = "python-whois" - result["registrar"] = str(data.registrar) if data.registrar else None - result["organization"] = str(getattr(data, "org", None)) if getattr(data, "org", None) else None - for key, attr in (("creation_date", "creation_date"), ("expiration_date", "expiration_date"), ("updated_date", "updated_date")): - value = getattr(data, attr, None) - if value: - result[key] = str(value) - result["name_servers"] = cls._unique(getattr(data, "name_servers", None) or []) - result["status"] = cls._unique(getattr(data, "status", None) or []) - result["emails"] = cls._unique(getattr(data, "emails", None) or []) - return result - except Exception as exc: - result["error"] = str(exc) - try: - proc = subprocess.run(["whois", domain], capture_output=True, text=True, timeout=20) - raw = (proc.stdout or proc.stderr or "").strip() - if raw: - result["available"] = True - result["source"] = "system-whois" - result["raw"] = raw - patterns = { - "registrar": r"(?im)^Registrar(?: Name)?:\s*(.+)$", - "organization": r"(?im)^(?:Registrant Organization|Organization):\s*(.+)$", - "creation_date": r"(?im)^(?:Creation Date|Created|Registered On):\s*(.+)$", - "expiration_date": r"(?im)^(?:Registry Expiry Date|Expiration Date|Expiry Date):\s*(.+)$", - "updated_date": r"(?im)^(?:Updated Date|Last Updated):\s*(.+)$", - } - for key, pattern in patterns.items(): - match = re.search(pattern, raw) - if match: - result[key] = match.group(1).strip() - result["name_servers"] = cls._unique(re.findall(r"(?im)^(?:Name Server|Nameserver|nserver):\s*([^\s]+)", raw)) - except Exception as exc: - result["error"] = str(exc) - return result - - @classmethod - def _dns(cls, domain): - result = {"records": {}, "errors": {}, "resolved_ips": []} - try: - import dns.resolver - resolver = dns.resolver.Resolver() - resolver.timeout = cls.DNS_TIMEOUT - resolver.lifetime = cls.DNS_TIMEOUT + 1 - for rtype in cls.DNS_TYPES: - values = [] - try: - answers = resolver.resolve(domain, rtype, raise_on_no_answer=False) - for answer in answers: - if rtype == "MX": - values.append(f"{answer.exchange} (pref: {answer.preference})") - elif rtype == "SOA": - values.append(str(answer)) - else: - values.append(str(answer).strip('"').rstrip('.')) - except Exception as exc: - result["errors"][rtype] = str(exc) - result["records"][rtype] = cls._unique(values) - except ImportError: - result["errors"]["dns"] = "dnspython is not installed" - except Exception as exc: - result["errors"]["dns"] = str(exc) - try: - result["resolved_ips"] = cls._unique( - result["records"].get("A", []) + result["records"].get("AAAA", []) - ) - if not result["resolved_ips"]: - result["resolved_ips"] = cls._unique( - item[4][0] for item in socket.getaddrinfo(domain, None) - ) - except Exception: - pass - return result - - @classmethod - def _spf(cls, dns_data): - records = [x for x in dns_data.get("records", {}).get("TXT", []) if x.lower().startswith("v=spf1")] - return {"present": bool(records), "records": records, "mechanisms": cls._unique(" ".join(records).split()[1:])} - - @classmethod - def _dmarc(cls, domain): - result = {"present": False, "record": None, "policy": None} - try: - import dns.resolver - resolver = dns.resolver.Resolver() - resolver.timeout = cls.DNS_TIMEOUT - resolver.lifetime = cls.DNS_TIMEOUT + 1 - values = [str(x).strip('"') for x in resolver.resolve(f"_dmarc.{domain}", "TXT")] - record = next((x for x in values if x.lower().startswith("v=dmarc1")), None) - if record: - result["present"] = True - result["record"] = record - match = re.search(r"(?:^|;)\s*p=([^;]+)", record, re.I) - result["policy"] = match.group(1).strip().lower() if match else None - except Exception as exc: - result["error"] = str(exc) - return result - - @classmethod - def _dkim(cls, domain): - result = {"tested": list(cls.DKIM_SELECTORS), "found": []} - try: - import dns.resolver - resolver = dns.resolver.Resolver() - resolver.timeout = 2 - resolver.lifetime = 3 - for selector in cls.DKIM_SELECTORS: - try: - values = [str(x).strip('"') for x in resolver.resolve(f"{selector}._domainkey.{domain}", "TXT")] - if values: - result["found"].append({"selector": selector, "records": values}) - except Exception: - continue - except ImportError: - result["error"] = "dnspython is not installed" - return result - - @staticmethod - def _security_headers(headers): - normalized = {str(k).lower(): str(v) for k, v in headers.items()} - return {key: normalized.get(key) for key in DomainScanner.SECURITY_HEADERS} - - @classmethod - def _technologies(cls, response): - body = (response.text or "")[:1500000] - blob = body + "\n" + "\n".join(f"{k}: {v}" for k, v in response.headers.items()) - found = set() - for name, patterns in cls.TECH_PATTERNS.items(): - if any(re.search(p, blob, re.I) for p in patterns): - found.add(name) - if response.headers.get("Server"): - found.add("Server: " + response.headers["Server"].strip()) - if response.headers.get("X-Powered-By"): - found.add("Powered-By: " + response.headers["X-Powered-By"].strip()) - return sorted(found) - - @classmethod - def _cdn(cls, headers): - blob = "\n".join(f"{k}: {v}" for k, v in headers.items()).lower() - return sorted(name for name, patterns in cls.CDN_PATTERNS.items() if any(p.lower() in blob for p in patterns)) - - @staticmethod - def _title(body): - match = re.search(r"]*>(.*?)", body or "", re.I | re.S) - return re.sub(r"\s+", " ", html.unescape(match.group(1))).strip()[:300] if match else None - - @classmethod - def _web(cls, domain): - result = {"preferred_url": None, "http": {}, "https": {}, "redirect_chain": [], - "headers": {}, "security_headers": {}, "server": None, - "content_type": None, "title": None, "technologies": [], "cdn": []} - responses = {} - for scheme in ("https", "http"): - response = cls._http(f"{scheme}://{domain}/") - if response is not None: - responses[scheme] = response - result[scheme] = { - "available": True, "status": response.status_code, - "final_url": response.url, - "history": [{"status": x.status_code, "url": x.url, "location": x.headers.get("Location")} for x in response.history], - "headers": dict(response.headers), - } - else: - result[scheme] = {"available": False} - response = responses.get("https") or responses.get("http") - if response is not None: - result["preferred_url"] = response.url - result["redirect_chain"] = [x.url for x in response.history] + [response.url] - result["headers"] = dict(response.headers) - result["security_headers"] = cls._security_headers(response.headers) - result["server"] = response.headers.get("Server") - result["content_type"] = response.headers.get("Content-Type") - result["title"] = cls._title(response.text) - result["technologies"] = cls._technologies(response) - result["cdn"] = cls._cdn(response.headers) - return result - - @classmethod - def _tls(cls, domain): - result = {"available": False, "version": None, "cipher": None, "subject": {}, "issuer": {}, "san": [], "not_before": None, "not_after": None} - try: - context = ssl.create_default_context() - with socket.create_connection((domain, 443), timeout=8) as sock: - with context.wrap_socket(sock, server_hostname=domain) as tls: - cert = tls.getpeercert() - result["available"] = True - result["version"] = tls.version() - result["cipher"] = tls.cipher() - result["subject"] = dict(x[0] for x in cert.get("subject", ())) - result["issuer"] = dict(x[0] for x in cert.get("issuer", ())) - result["not_before"] = cert.get("notBefore") - result["not_after"] = cert.get("notAfter") - result["san"] = cls._unique(v for k, v in cert.get("subjectAltName", ()) if k.lower() == "dns") - except Exception as exc: - result["error"] = str(exc) - return result - - @classmethod - def _ct(cls, domain): - result = {"available": False, "source": "crt.sh", "certificates": 0, "names": []} - try: - import requests - response = requests.get( - "https://crt.sh/", params={"q": f"%.{domain}", "output": "json"}, - headers={"User-Agent": cls.USER_AGENT}, timeout=cls.CT_TIMEOUT, verify=True, - ) - if response.status_code != 200: - result["error"] = f"HTTP {response.status_code}" - return result - records = response.json() - names = set() - for record in records: - for name in str(record.get("name_value", "")).splitlines(): - name = name.strip().lower().replace("*.", "") - if name == domain or name.endswith("." + domain): - names.add(name) - result.update({"available": True, "certificates": len(records), "names": sorted(names)}) - except Exception as exc: - result["error"] = str(exc) - return result - - @classmethod - def _subdomains(cls, domain, ct): - candidates = set(x for x in ct.get("names", []) if x != domain and x.endswith("." + domain)) - for prefix in COMMON_SUBDOMAINS: - candidates.add(f"{prefix}.{domain}") - resolved = [] - for host in sorted(candidates): - try: - ips = cls._unique(item[4][0] for item in socket.getaddrinfo(host, None)) - if ips: - resolved.append({"hostname": host, "ips": ips}) - except Exception: - pass - return {"candidates": sorted(candidates), "resolved": resolved, "count": len(resolved)} - - @classmethod - def _robots(cls, domain): - result = {"found": False, "status": None, "url": None, "sitemaps": [], "disallow": [], "allow": []} - for scheme in ("https", "http"): - response = cls._http(f"{scheme}://{domain}/robots.txt") - if response is not None and response.status_code == 200: - result.update({"found": True, "status": response.status_code, "url": response.url}) - for line in response.text.splitlines(): - line = line.strip() - if not line or line.startswith("#") or ":" not in line: - continue - key, value = line.split(":", 1) - value = value.strip() - if key.lower() == "sitemap": result["sitemaps"].append(value) - elif key.lower() == "disallow" and value: result["disallow"].append(value) - elif key.lower() == "allow" and value: result["allow"].append(value) - result["sitemaps"] = cls._unique(result["sitemaps"]) - result["disallow"] = cls._unique(result["disallow"]) - result["allow"] = cls._unique(result["allow"]) - return result - return result - - @classmethod - def _sitemap(cls, domain, robots): - candidates = cls._unique(robots.get("sitemaps", []) + [f"https://{domain}/sitemap.xml", f"https://{domain}/sitemap_index.xml"]) - for url in candidates: - response = cls._http(url) - if response is None or response.status_code != 200: - continue - body = response.text - if "\s*(.*?)\s*", body, re.I | re.S)) - return {"found": True, "url": response.url, "status": response.status_code, "urls": urls[:5000]} - return {"found": False, "url": None, "status": None, "urls": []} - - @classmethod - def _reverse_dns(cls, ips): - result = {} - for ip in ips: - try: - result[ip] = socket.gethostbyaddr(ip)[0] - except Exception: - result[ip] = None - return result - - @classmethod - def _infrastructure(cls, dns_data): - ips = dns_data.get("resolved_ips", []) - result = {"ips": ips, "reverse_dns": cls._reverse_dns(ips)} - api_key = os.environ.get("SHODAN_API_KEY") - if api_key and ips: - try: - import requests - response = requests.get( - f"https://api.shodan.io/shodan/host/{ips[0]}", - params={"key": api_key}, headers={"User-Agent": cls.USER_AGENT}, timeout=10, - ) - if response.status_code == 200: - data = response.json() - result["shodan"] = { - "ip": data.get("ip_str"), "organization": data.get("org"), - "isp": data.get("isp"), "asn": data.get("asn"), - "os": data.get("os"), "ports": data.get("ports", []), - } - except Exception as exc: - result["shodan_error"] = str(exc) - return result - - @classmethod - def _score(cls, dns_data, spf, dmarc, dkim, web, tls): - score = 0 - if dns_data.get("records", {}).get("A") or dns_data.get("records", {}).get("AAAA"): score += 10 - if dns_data.get("records", {}).get("NS"): score += 5 - if dns_data.get("records", {}).get("SOA"): score += 5 - if dns_data.get("records", {}).get("CAA"): score += 5 - if spf.get("present"): score += 8 - if dmarc.get("present"): score += 8 - if dmarc.get("policy") in ("quarantine", "reject"): score += 4 - if dkim.get("found"): score += 5 - if tls.get("available"): score += 15 - if tls.get("version") in ("TLSv1.2", "TLSv1.3"): score += 5 - if web.get("https", {}).get("available"): score += 8 - headers = web.get("security_headers", {}) - score += min(17, sum(2 for value in headers.values() if value)) - score = min(100, score) - rating = "EXCELLENT" if score >= 90 else "STRONG" if score >= 75 else "MODERATE" if score >= 60 else "WEAK" if score >= 40 else "HIGH EXPOSURE" - return {"score": score, "rating": rating} - - @classmethod - def _findings(cls, spf, dmarc, caa, dnssec, web, tls, robots, ct): - findings = [] - if not spf.get("present"): findings.append({"severity": "MEDIUM", "category": "Email", "message": "No SPF record observed."}) - if not dmarc.get("present"): findings.append({"severity": "MEDIUM", "category": "Email", "message": "No DMARC record observed."}) - elif dmarc.get("policy") == "none": findings.append({"severity": "LOW", "category": "Email", "message": "DMARC policy is set to none."}) - if not caa.get("records"): findings.append({"severity": "LOW", "category": "DNS", "message": "No CAA record observed."}) - if not dnssec.get("present"): findings.append({"severity": "LOW", "category": "DNS", "message": "DNSSEC evidence was not observed."}) - missing = [k for k, v in web.get("security_headers", {}).items() if not v] - if missing: findings.append({"severity": "LOW", "category": "Web", "message": f"{len(missing)} recommended security headers were not observed.", "details": missing}) - if web.get("https", {}).get("available") and not web.get("security_headers", {}).get("strict-transport-security"): - findings.append({"severity": "LOW", "category": "TLS", "message": "HTTPS is available but HSTS was not observed."}) - if not tls.get("available"): findings.append({"severity": "MEDIUM", "category": "TLS", "message": "TLS certificate inspection failed."}) - if robots.get("disallow"): findings.append({"severity": "INFO", "category": "Web", "message": "robots.txt exposes disallowed paths.", "details": robots["disallow"][:50]}) - if ct.get("names"): findings.append({"severity": "INFO", "category": "CT", "message": f"{len(ct['names'])} certificate names discovered."}) - return findings - - @classmethod - def scan(cls, domain: str, report_manager: ReportManager): - started = time.time() - domain = cls.normalize_domain(domain) - if not domain or len(domain) > 253 or " " in domain: - cls._write(report_manager, "[-] Invalid domain.") - return None - - cls._write(report_manager, f"\n-----DOMAIN INTELLIGENCE SCAN: {domain}-----\n") - data = {"module": "Domain Intelligence", "version": cls.VERSION, "domain": domain, - "timestamp": datetime.now().isoformat(), "whois": {}, "dns": {}, "spf": {}, - "dmarc": {}, "dkim": {}, "caa": {}, "dnssec": {}, "certificate_transparency": {}, - "subdomains": {}, "infrastructure": {}, "web": {}, "tls": {}, "robots": {}, - "sitemap": {}, "findings": [], "score": {}} - - steps = [ - ("WHOIS", lambda: cls._whois(domain), "whois"), - ("DNS", lambda: cls._dns(domain), "dns"), - ] - for label, func, key in steps: - cls._write(report_manager, f"[*] Collecting {label}...") - try: data[key] = func() - except Exception as exc: data[key] = {"error": str(exc)} - - try: data["spf"] = cls._spf(data["dns"]) - except Exception as exc: data["spf"] = {"error": str(exc), "present": False} - try: data["dmarc"] = cls._dmarc(domain) - except Exception as exc: data["dmarc"] = {"error": str(exc), "present": False} - try: data["dkim"] = cls._dkim(domain) - except Exception as exc: data["dkim"] = {"error": str(exc), "found": []} - - records = data["dns"].get("records", {}) - data["caa"] = {"present": bool(records.get("CAA")), "records": records.get("CAA", [])} - data["dnssec"] = {"present": bool(records.get("DS") or records.get("DNSKEY")), "records": {"DS": records.get("DS", []), "DNSKEY": records.get("DNSKEY", [])}} - - cls._write(report_manager, "[*] Querying Certificate Transparency...") - data["certificate_transparency"] = cls._ct(domain) - data["subdomains"] = cls._subdomains(domain, data["certificate_transparency"]) - cls._write(report_manager, "[*] Inspecting HTTP/HTTPS and TLS...") - data["web"] = cls._web(domain) - data["tls"] = cls._tls(domain) - data["robots"] = cls._robots(domain) - data["sitemap"] = cls._sitemap(domain, data["robots"]) - data["infrastructure"] = cls._infrastructure(data["dns"]) - data["related_hosts"] = cls._unique( - records.get("NS", []) + records.get("CNAME", []) + records.get("MX", []) - ) - data["findings"] = cls._findings(data["spf"], data["dmarc"], data["caa"], data["dnssec"], data["web"], data["tls"], data["robots"], data["certificate_transparency"]) - data["score"] = cls._score(data["dns"], data["spf"], data["dmarc"], data["dkim"], data["web"], data["tls"]) - data["runtime_seconds"] = round(time.time() - started, 2) - - cls._write(report_manager, "\n[ WHOIS ]") - whois_data = data["whois"] - for key in ("registrar", "organization", "creation_date", "updated_date", "expiration_date"): - cls._write(report_manager, f"{key.replace('_', ' ').title()}: {whois_data.get(key) or 'Unknown'}") - cls._write(report_manager, "Name Servers: " + (", ".join(whois_data.get("name_servers", [])) or "Unknown")) - - cls._write(report_manager, "\n[ DNS ]") - for rtype in cls.DNS_TYPES: - values = records.get(rtype, []) - if values: cls._write(report_manager, f"{rtype}: {', '.join(values[:25])}") - - cls._write(report_manager, "\n[ EMAIL SECURITY ]") - cls._write(report_manager, f"SPF: {'FOUND' if data['spf'].get('present') else 'NOT FOUND'}") - cls._write(report_manager, f"DMARC: {data['dmarc'].get('policy') or 'NOT FOUND'}") - cls._write(report_manager, f"DKIM selectors found: {len(data['dkim'].get('found', []))}") - - cls._write(report_manager, "\n[ WEB / TLS ]") - cls._write(report_manager, f"URL: {data['web'].get('preferred_url') or 'Unavailable'}") - cls._write(report_manager, f"Title: {data['web'].get('title') or 'Unknown'}") - cls._write(report_manager, f"Server: {data['web'].get('server') or 'Unknown'}") - cls._write(report_manager, "Technologies: " + (", ".join(data['web'].get('technologies', [])) or "None detected")) - cls._write(report_manager, "CDN/WAF: " + (", ".join(data['web'].get('cdn', [])) or "None detected")) - cls._write(report_manager, f"TLS: {data['tls'].get('version') or 'Unavailable'}") - cls._write(report_manager, f"TLS SANs: {len(data['tls'].get('san', []))}") - - cls._write(report_manager, "\n[ CERTIFICATE TRANSPARENCY / SUBDOMAINS ]") - cls._write(report_manager, f"Certificates: {data['certificate_transparency'].get('certificates', 0)}") - cls._write(report_manager, f"Resolved subdomains: {data['subdomains'].get('count', 0)}") - for item in data["subdomains"].get("resolved", [])[:100]: - cls._write(report_manager, f" {item['hostname']} -> {', '.join(item['ips'])}") - - cls._write(report_manager, "\n[ PUBLIC RESOURCES ]") - cls._write(report_manager, f"robots.txt: {'FOUND' if data['robots'].get('found') else 'NOT FOUND'}") - cls._write(report_manager, f"sitemap: {'FOUND' if data['sitemap'].get('found') else 'NOT FOUND'}") - - cls._write(report_manager, "\n[ FINDINGS ]") - for finding in data["findings"] or [{"severity": "INFO", "category": "General", "message": "No notable findings."}]: - cls._write(report_manager, f"[{finding['severity']}] {finding['category']}: {finding['message']}") - - cls._write(report_manager, f"\n[ INTELLIGENCE SCORE ] {data['score']['score']}/100 — {data['score']['rating']}") - cls._write(report_manager, f"[*] DOMAIN INTELLIGENCE COMPLETE in {data['runtime_seconds']:.2f}s") - return data - -# ============== IP Scanner ============== -class IPScanner: - """Integrates SpectraScan IP Scanner features""" - @staticmethod - def scan(ip: str, report_manager: ReportManager): - report_manager.write(f"\n-----IP SCAN OF {ip}-----\n") - - # GeoIP - report_manager.write("[*] LOCALISATION") - try: - result = subprocess.run( - ["curl", "-s", f"https://api.hackertarget.com/geoip/?q={ip}"], - capture_output=True, - text=True, - timeout=15, - ) - report_manager.write(result.stdout) - except FileNotFoundError: - report_manager.write("[-] 'curl' not found.") - except Exception as e: - report_manager.write(f"Error with GeoIP: {e}") - - # WHOIS - report_manager.write("\n[*] ADMIN INFO (WHOIS)") - report_manager.write(DomainScanner._run_whois(ip)) - - # Shodan - report_manager.write("\n[*] SHODAN RESULTS") - api_key = os.environ.get("SHODAN_API_KEY") - if not api_key: - report_manager.write("[-] SHODAN_API_KEY environment variable not set. Skipping Shodan lookup.") - else: - try: - import shodan - api = shodan.Shodan(api_key) - result = api.host(ip) - ports = result.get("ports", []) - report_manager.write(f"Open Ports: {len(ports)}") - for port in ports: - banner = "" - for entry in result.get("data", []): - if entry.get("port") == port: - banner = entry.get("product", "Unknown") - break - report_manager.write(f" Port {port}: {banner}") - except ImportError: - report_manager.write("[-] Shodan Python library not found. Install with: pip install shodan") - except Exception as e: - report_manager.write(f"Error with Shodan: {e}") - - report_manager.write("\n[*] DONE") - -# ============== Phone Scanner ============== -class PhoneScanner: - """Integrates SpectraScan Phone Scanner features""" - @staticmethod - def scan(phone: str, report_manager: ReportManager): - report_manager.write(f"\nScan of {phone}\n") - report_manager.write("Gathering Information...") - report_manager.write(f"\n[*] PHONE {phone}") - report_manager.write("-------------------------------------------------------------------------------") - - # Uses the external python script from the original codebase - script_path = os.path.join(os.path.dirname(__file__), "modules", "phone_scanner.py") - if os.path.exists(script_path): - try: - result = subprocess.run( - ["python3", script_path, phone], - capture_output=True, - text=True, - timeout=60, - ) - report_manager.write(result.stdout) - if result.stderr: - report_manager.write(f"Stderr: {result.stderr}") - except Exception as e: - report_manager.write(f"Error running phone scanner: {e}") - else: - report_manager.write("Phone scanner module not found (modules/phone_scanner.py).") - - report_manager.write("\n[*] DONE") - -# ============== Email Scanner ============== -class EmailScanner: - """Integrates SpectraScan Email Scanner features""" - @staticmethod - def scan(email: str, report_manager: ReportManager): - report_manager.write(f"\n[*] Gathering informations for {email}...") - try: - headers = {"User-Agent": "SpectraScan/1.0"} - try: - result = subprocess.run( - ["curl", "-s", "-H", f"User-Agent: {headers['User-Agent']}", - f"https://emailrep.io/{email}"], - capture_output=True, - text=True, - timeout=15, - ) - payload = result.stdout - except FileNotFoundError: - req = Request(f"https://emailrep.io/{email}", headers=headers) - with urlopen(req, timeout=10) as resp: - payload = resp.read().decode("utf-8", errors="ignore") - - json_data = json.loads(payload) if payload else {} - - details = json_data.get("details", {}) or {} - report_manager.write("\n_____FULL REPORT_____") - report_manager.write(f"-Email: {email}") - report_manager.write(f"-Suspicious: {json_data.get('suspicious')}") - report_manager.write(f"-Reputation: {json_data.get('reputation')}") - report_manager.write(f"-Blacklisted: {details.get('blacklisted')}") - report_manager.write(f"-Malicious Activity: {details.get('malicious_activity')}") - report_manager.write(f"-Data Breach: {details.get('data_breach')}") - report_manager.write(f"-First Seen: {details.get('first_seen')}") - report_manager.write(f"-Last Seen: {details.get('last_seen')}") - report_manager.write(f"-Domain Exists: {details.get('domain_exists')}") - report_manager.write(f"-Free Provider: {details.get('free_provider')}") - report_manager.write(f"-Disposable: {details.get('disposable')}") - report_manager.write(f"-Deliverable: {details.get('deliverable')}") - report_manager.write(f"-Spoofable: {details.get('spoofable')}") - except Exception as e: - report_manager.write(f"Error scraping emailrep: {e}") - report_manager.write("\n[*] DONE") - -# ============== Image Scanner ============== -class ImageScanner: - """Integrates SpectraScan Image EXIF Scanner features""" - @staticmethod - def scan(image_path: str, report_manager: ReportManager): - report_manager.write(f"\nEXIF data from: {image_path}") - if not os.path.exists(image_path): - report_manager.write(f"[-] File not found: {image_path}") - report_manager.write("\n[*] DONE") - return - try: - # Try exiv2 first, then exiftool - exiv2_path = "/usr/bin/exiv2" if sys.platform != "win32" else r"C:\Program Files\exiv2\exiv2.exe" - exiftool_path = "/usr/bin/exiftool" if sys.platform != "win32" else r"C:\Windows\exiftool.exe" - if os.path.exists(exiv2_path): - result = subprocess.run( - ["exiv2", image_path], capture_output=True, text=True, timeout=30 - ) - report_manager.write(result.stdout) - elif os.path.exists(exiftool_path): - result = subprocess.run( - ["exiftool", image_path], capture_output=True, text=True, timeout=30 - ) - report_manager.write(result.stdout) - else: - report_manager.write("No EXIF tool found (exiv2 or exiftool required).") - except FileNotFoundError: - report_manager.write("No EXIF tool found (exiv2 or exiftool required).") - except Exception as e: - report_manager.write(f"Error reading EXIF: {e}") - report_manager.write("\n[*] DONE") - -# ============== Link Scanner ============== -class LinkScanner: - """Integrates SpectraScann Link Sniffing features""" - @staticmethod - def scan(domain: str, report_manager: ReportManager): - report_manager.write(f"\n__________Link Sniffing__________\n") - report_manager.write(f"[*] SNIFFING LINKS for {domain}") - report_manager.write("-------------------------------------------------------------------------------") - try: - result = subprocess.run( - ["curl", "-s", f"https://api.hackertarget.com/pagelinks/?q={domain}"], - capture_output=True, - text=True, - timeout=20, - ) - report_manager.write(result.stdout or "[-] No response from API.") - except FileNotFoundError: - report_manager.write("[-] 'curl' not found.") - except Exception as e: - report_manager.write(f"Error with link sniffing: {e}") - report_manager.write("\n[*] DONE") - -# ============== Criminal Scanner ============== -class CriminalScanner: - """Integrates SpectraScan Criminal Scanner features""" - @staticmethod - def scan(first_name: str, last_name: str, state: str, city: str, report_manager: ReportManager): - state_prefix = f"{state}." if state else "" - link = f"https://{state_prefix}staterecords.org/search.php?firstname={first_name}&lastname={last_name}&city={city}" - report_manager.write("\n[*] Generating Link...") - report_manager.write(f"\n\nCTRL + click on this link to get your report: [{link}]") - report_manager.write("\n[*] DONE") - -# ============== CLI ============== -def print_banner(): - """Prints the SpectraScan Title Banner""" - banner_text = """ - ================================================ - SPECTRASCAN - ENHANCED EDITION - ================================================ - """ - console.print(Panel(banner_text, border_style="cyan", style="bold cyan")) - console.print("[*] Initializing SpectraScan Core...", style="green") - time.sleep(0.5) - console.print("[+] Core Loaded. Awaiting Input.\n", style="green") - - -def guard_interrupt(fn): - """Decorator: catch Ctrl+C / Ctrl+D inside a submenu and bounce - back to the caller instead of killing the whole program.""" - @wraps(fn) - def wrapper(*args, **kwargs): - try: - return fn(*args, **kwargs) - except (KeyboardInterrupt, EOFError): - console.print("\n[!] Cancelled. Returning to previous menu.", style="yellow") - return None - return wrapper - - -def hacker_input(prompt_text: str, default: str = "") -> str: - """Simulates a terminal input with nice formatting""" - if default: - prompt = f"{CYAN}root@spectra:~#{RESET} {prompt_text} [{default}]: " - else: - prompt = f"{CYAN}root@spectra:~#{RESET} {prompt_text}: " - - user_input = input(prompt).strip() - return user_input if user_input else default - - -def parse_ports(ports_input: str): - """Turn a ports string into a list[int], or None if invalid. - Supports comma lists, ranges (80-100), 'all', and 'common'.""" - ports_input = ports_input.lower().strip() - - if ports_input == "all": - return list(range(1, 65536)) - if ports_input == "common": - return list(COMMON_PORTS.keys()) - - ports = set() - try: - for chunk in ports_input.split(","): - chunk = chunk.strip() - if not chunk: - continue - if "-" in chunk: - start, end = chunk.split("-", 1) - start, end = int(start), int(end) - if not (1 <= start <= end <= 65535): - raise ValueError - ports.update(range(start, end + 1)) - else: - p = int(chunk) - if not (1 <= p <= 65535): - raise ValueError - ports.add(p) - except ValueError: - return None - - return sorted(ports) if ports else None - - -def run_network_port_scan( - network_target: str, - scan_kwargs: dict, -): - """Scan each usable host in a CIDR network.""" - - try: - network = ipaddress.ip_network( - network_target, - strict=False, - ) - except ValueError as exc: - console.print( - f"[!] Invalid network: {exc}", - style="bold red", - ) - return [] - - hosts = list(network.hosts()) - - if not hosts: - console.print( - "[!] No usable hosts found.", - style="bold red", - ) - return [] - - if len(hosts) > 256: - proceed = Confirm.ask( - f"[!] Network contains {len(hosts)} hosts. Continue?", - default=False, - ) - if not proceed: - console.print( - "[*] Network scan cancelled.", - style="yellow", - ) - return [] - - console.print( - "\n[bold cyan]" - "============================================================" - "[/bold cyan]" - ) - console.print( - f"[bold green][+] Network Scan: {network}[/bold green]" - ) - console.print( - f"[cyan][*] Hosts: {len(hosts)}[/cyan]" - ) - console.print( - "[bold cyan]" - "============================================================" - "[/bold cyan]\n" - ) - - all_results = [] - - for index, host in enumerate(hosts, start=1): - host_ip = str(host) - - console.print( - f"\n[bold yellow]" - f"[{index}/{len(hosts)}] Scanning {host_ip}" - f"[/bold yellow]" - ) - - try: - resolved_ip = resolve_host(host_ip) - - scanner = PortScanner( - host_ip, - **scan_kwargs, - ) - - scanner.resolved_ip = resolved_ip - scanner.scan() - - result = scanner.get_results() - - if isinstance(result, dict): - all_results.append(result) - - scanner.print_summary() - - except KeyboardInterrupt: - console.print( - "\n[!] Network scan interrupted.", - style="yellow", - ) - break - - except Exception as exc: - console.print( - f"[!] {host_ip}: {exc}", - style="red", - ) - - total_open = sum( - len(result.get("open_ports", [])) - for result in all_results - if isinstance(result, dict) - ) - - console.print( - "\n[bold cyan]" - "============================================================" - "[/bold cyan]" - ) - console.print( - "[bold green][✓] Network scan complete[/bold green]" - ) - console.print( - f"[cyan][*] Hosts processed: " - f"{len(all_results)}/{len(hosts)}[/cyan]" - ) - console.print( - f"[cyan][*] Total open ports: {total_open}[/cyan]" - ) - console.print( - "[bold cyan]" - "============================================================" - "[/bold cyan]" - ) - - return all_results - - -@guard_interrupt -def run_port_scan_cli(): - """Interactive Port Scan Menu.""" - - console.print( - "\n[bold cyan]--- PORT SCANNER MODULE ---[/bold cyan]" - ) - - target = hacker_input( - "Enter Target IP, CIDR, Hostname, or URL" - ) - - if not target: - console.print( - "[!] Target required. Returning to menu.", - style="red", - ) - return - - target = normalize_target(target) - - if not target: - console.print( - "[!] Invalid target.", - style="red", - ) - return - - scan_type = Prompt.ask( - "Scan Type", - choices=["tcp", "syn", "udp"], - default="tcp", - ) - - timing = Prompt.ask( - "Timing Profile", - choices=["T0", "T1", "T2", "T3", "T4", "T5"], - default="T3", - ) - - ports_input = hacker_input( - "Enter Ports (e.g. 80,443,8080 or 1-1024), " - "'common', or 'all'", - "common", - ) - - ports = parse_ports(ports_input) - - if ports is None: - console.print( - "[!] Invalid port format. Returning to menu.", - style="red", - ) - return - - if len(ports) > 5000: - proceed = Confirm.ask( - f"[!] {len(ports)} ports selected — this may take a while. Continue?", - default=True, - ) - if not proceed: - console.print( - "[*] Scan cancelled.", - style="yellow", - ) - return - - check_vulns = Confirm.ask( - "Check for Vulnerabilities?", - default=False, - ) - - console.print( - f"\n[+] Spectra Starting Scan on {target}...", - style="bold yellow", - ) - - scan_kwargs = { - "timeout": 1.0, - "threads": 50, - "scan_type": scan_type, - "timing": timing, - "ports": ports, - "decoys": [], - "check_vulns": check_vulns, - "rate_limit": 0, - } - - try: - network = ipaddress.ip_network( - target, - strict=False, - ) - - console.print( - f"[cyan][*] CIDR network detected: {network}[/cyan]" - ) - - run_network_port_scan( - str(network), - scan_kwargs, - ) - return - - except ValueError: - pass - - try: - resolved_ip = resolve_host(target) - - console.print( - f"[cyan][*] Resolved: " - f"{target} -> {resolved_ip}[/cyan]" - ) - except Exception as exc: - console.print( - f"[!] Cannot resolve target: {exc}", - style="bold red", - ) - return - - try: - scanner = PortScanner( - target, - **scan_kwargs, - ) - - scanner.resolved_ip = resolved_ip - scanner.scan() - - except KeyboardInterrupt: - console.print( - "[!] Scan interrupted by user.", - style="yellow", - ) - return - - except Exception as exc: - console.print( - f"[!] Scan failed: {exc}", - style="bold red", - ) - return - - scanner.print_summary() - - if not scanner.results: - console.print( - "[*] No results to export.", - style="yellow", - ) - return - - if not Confirm.ask( - "Export Results?", - default=True, - ): - return - - fmt = Prompt.ask( - "Format", - choices=["json", "html", "csv"], - default="json", - ) - - filename = hacker_input( - "Filename (without extension)", - "scan_report", - ) - - filename = "".join( - char - for char in filename - if char.isalnum() or char in ("_", "-") - ) or "scan_report" - - full_path = f"{filename}.{fmt}" - - try: - if fmt == "json": - scanner.export_json(full_path) - elif fmt == "html": - scanner.export_html(full_path) - else: - scanner.export_csv(full_path) - - console.print( - f"[+] Results exported to {full_path}", - style="green", - ) - - except Exception as exc: - console.print( - f"[!] Export failed: {exc}", - style="bold red", - ) - -@guard_interrupt -def run_other_scanners(): - """Interactive menu for Domain, IP, Email, etc.""" - console.print("\n[bold cyan]--- ADVANCED MODULES ---[/bold cyan]") - mode = Prompt.ask("Select Module", choices=[ - "domain", "ip", "phone", "email", "image", "link", "criminal", "reports" - ]) - - rm = ReportManager() - - module_map = { - "domain": ("Enter Domain", DomainScanner), - "ip": ("Enter IP Address", IPScanner), - "phone": ("Enter Phone Number", PhoneScanner), - "email": ("Enter Email Address", EmailScanner), - "image": ("Enter Image Path", ImageScanner), - "link": ("Enter Domain for Link Sniffing", LinkScanner), - } - - if mode in module_map: - prompt_text, scanner_cls = module_map[mode] - value = hacker_input(prompt_text) - if not value: - console.print("[!] Input required. Returning to menu.", style="red") - return - try: - scanner_cls.scan(value, rm) - except Exception as e: - console.print(f"[!] {mode.capitalize()} scan failed: {e}", style="bold red") - - elif mode == "criminal": - first = hacker_input("First Name") - last = hacker_input("Last Name") - if not first or not last: - console.print("[!] First and last name are required.", style="red") - return - state = hacker_input("State (Optional)") - city = hacker_input("City") - try: - CriminalScanner.scan(first, last, state, city, rm) - except Exception as e: - console.print(f"[!] Criminal lookup failed: {e}", style="bold red") - - elif mode == "reports": - action = Prompt.ask("Action", choices=["read", "delete"]) - try: - if action == "read": - rm.read_report() - elif action == "delete": - if Confirm.ask("Are you sure you want to delete a report?", default=False): - rm.delete_report() - else: - console.print("[*] Delete cancelled.", style="yellow") - except Exception as e: - console.print(f"[!] Report action failed: {e}", style="bold red") - - -MENU_ACTIONS = { - "1": ("Port Scanner", run_port_scan_cli), - "2": ("Advanced Modules (Domain/IP/Email/etc)", run_other_scanners), - "3": ("Protocol Modules (SMB/SNMP/LDAP/RDP/etc)", lambda: run_protocol_modules()), -} - - -def print_main_menu(): - console.print("\n[bold]MAIN MENU[/bold]") - for key, (label, _) in MENU_ACTIONS.items(): - style = "bold green" if key in ("1", "2") else "bold magenta" - console.print(f"[{style}]{key}.[/{style}] {label}") - console.print("[bold red]4.[/bold red] Exit") - - -def main(): - # Legacy CLI args for backward compatibility / scripting - if len(sys.argv) > 1: - parser = argparse.ArgumentParser(description="SpectraScan Legacy CLI") +from modules.async_scanner import AsyncPortScanner +from modules.report_store import ReportStore, atomic_write_text, atomic_write_json, safe_csv_cell +import io + +sys.path.append(os.path.join(os.path.dirname(__file__), "modules")) + +# Initialize Rich Console +console = Console() + + + +import hashlib +import html as html_mod +import ipaddress +import json +import os +import re +import socket +import ssl +import time +import urllib.parse +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, List, Optional, Tuple + +# --------------------------------------------------------------------- +# Optional dependencies +# --------------------------------------------------------------------- + +try: + import socks # type: ignore + + SOCKS_AVAILABLE = True +except ImportError: + socks = None + SOCKS_AVAILABLE = False + +try: + import requests + + REQUESTS_AVAILABLE = True +except ImportError: + requests = None + REQUESTS_AVAILABLE = False + +try: + from cryptography import x509 + from cryptography.hazmat.backends import default_backend + + CRYPTOGRAPHY_AVAILABLE = True +except ImportError: + x509 = None + default_backend = None + CRYPTOGRAPHY_AVAILABLE = False + +try: + import dns.resolver # type: ignore + + DNS_AVAILABLE = True +except ImportError: + dns = None + DNS_AVAILABLE = False + +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Confirm, Prompt +from rich.table import Table + +console = Console() + +# --------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------- + +MODULE_NAME = "SpectraScan Dark Web Intelligence" +MODULE_VERSION = "2.0.0" + +TOR_SOCKS_HOST = os.getenv("TOR_HOST", "127.0.0.1") + +try: + TOR_SOCKS_PORT = int(os.getenv("TOR_PORT", "9050")) +except ValueError: + TOR_SOCKS_PORT = 9050 + +TOR_HTTP_PROXY = f"socks5h://{TOR_SOCKS_HOST}:{TOR_SOCKS_PORT}" + +DEFAULT_TIMEOUT = float(os.getenv("SPECTRASCAN_TIMEOUT", "20")) +MAX_HTTP_BODY = int(os.getenv("SPECTRASCAN_MAX_BODY", "500000")) +MAX_REDIRECTS = int(os.getenv("SPECTRASCAN_MAX_REDIRECTS", "5")) + +USER_AGENT = ( + f"SpectraScan/{MODULE_VERSION} " + "(authorized-security-research)" +) + +# --------------------------------------------------------------------- +# Regex +# --------------------------------------------------------------------- + +ONION_V3_REGEX = re.compile( + r"(?'\"\\]+", + re.IGNORECASE, +) + +BTC_BASE58_REGEX = re.compile( + r"^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$" +) + +BTC_BECH32_REGEX = re.compile( + r"^bc1[ac-hj-np-z02-9]{11,87}$", + re.IGNORECASE, +) + +BTC_TX_REGEX = re.compile( + r"^[a-fA-F0-9]{64}$" +) + +ETH_REGEX = re.compile( + r"^0x[a-fA-F0-9]{40}$" +) + +ETH_TX_REGEX = re.compile( + r"^0x[a-fA-F0-9]{64}$" +) + +XMR_REGEX = re.compile( + r"^4[0-9AB][0-9a-zA-Z]{93}$" +) + +LTC_REGEX = re.compile( + r"^[LM3][a-km-zA-HJ-NP-Z1-9]{26,33}$" +) + +MD5_REGEX = re.compile(r"^[a-fA-F0-9]{32}$") +SHA1_REGEX = re.compile(r"^[a-fA-F0-9]{40}$") +SHA256_REGEX = re.compile(r"^[a-fA-F0-9]{64}$") +SHA512_REGEX = re.compile(r"^[a-fA-F0-9]{128}$") + +PGP_BEGIN_REGEX = re.compile( + r"-----BEGIN PGP PUBLIC KEY BLOCK-----", + re.IGNORECASE, +) + +PHONE_REGEX = re.compile( + r"^\+?\d{1,3}" + r"[\s.-]?" + r"\(?\d{1,4}\)?" + r"[\s.-]?" + r"\d{3,4}" + r"[\s.-]?" + r"\d{3,4}$" +) + +USERNAME_REGEX = re.compile( + r"^[a-zA-Z][a-zA-Z0-9_.-]{2,31}$" +) + +DISPOSABLE_DOMAINS = { + "10minutemail.com", + "dispostable.com", + "fakeinbox.com", + "getnada.com", + "guerrillamail.com", + "maildrop.cc", + "mailinator.com", + "sharklasers.com", + "tempmail.com", + "throwaway.email", + "trashmail.com", + "yopmail.com", +} + +SUSPICIOUS_TLDS = { + "cf", + "click", + "ga", + "gq", + "icu", + "ml", + "online", + "rest", + "site", + "top", + "tk", + "xyz", +} + +SENSITIVE_HEADERS = { + "server", + "x-powered-by", + "x-aspnet-version", + "x-generator", +} + +SECURITY_HEADERS = { + "strict-transport-security": "HSTS", + "content-security-policy": "CSP", + "x-frame-options": "X-Frame-Options", + "x-content-type-options": "X-Content-Type-Options", + "referrer-policy": "Referrer-Policy", + "permissions-policy": "Permissions-Policy", + "cross-origin-opener-policy": "COOP", + "cross-origin-resource-policy": "CORP", + "cross-origin-embedder-policy": "COEP", +} + +COMMON_TECH_SIGNATURES = { + "WordPress": [ + r"/wp-content/", + r"/wp-includes/", + r"wp-json", + ], + "Drupal": [ + r"drupalSettings", + r"/sites/default/", + r"Drupal.settings", + ], + "Joomla": [ + r"/media/system/", + r"Joomla!", + ], + "PHP": [ + r"\.php(?:[?#]|$)", + r"PHPSESSID", + ], + "Laravel": [ + r"laravel_session", + r"Laravel", + ], + "Django": [ + r"csrftoken", + r"__django", + ], + "nginx": [ + r"nginx", + ], + "Apache": [ + r"Apache", + ], + "Cloudflare": [ + r"cloudflare", + r"cf-ray", + ], + "React": [ + r"react", + r"__NEXT_DATA__", + ], + "Next.js": [ + r"__NEXT_DATA__", + r"_next/static/", + ], + "Vue.js": [ + r"vue", + r"__vue__", + ], +} + +# --------------------------------------------------------------------- +# Public APIs +# --------------------------------------------------------------------- + +BLOCKSTREAM_API = "https://blockstream.info/api" +BLOCKCHAIR_API = "https://api.blockchair.com/bitcoin" +BLOCKCHAIN_INFO = "https://blockchain.info" +AHMIA_SEARCH = "https://ahmia.fi/search/?q=" + +# --------------------------------------------------------------------- +# Utility helpers +# --------------------------------------------------------------------- + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def safe_int(value: Any, default: Optional[int] = None) -> Optional[int]: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def safe_float( + value: Any, + default: Optional[float] = None, +) -> Optional[float]: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def normalize_target(target: str) -> str: + """ + Normalize a target without destroying URL path/query information. + """ + target = (target or "").strip() + + if not target: + return "" + + if "://" in target: + parsed = urllib.parse.urlsplit(target) + + host = parsed.hostname or "" + + if parsed.port: + host = f"{host}:{parsed.port}" + + path = parsed.path or "" + + normalized = f"{parsed.scheme.lower()}://{host}{path}" + + if parsed.query: + normalized += f"?{parsed.query}" + + return normalized.rstrip("/") + + return target.rstrip("/") + + +def normalize_domain(value: str) -> str: + value = value.strip().lower() + + if "://" in value: + value = urllib.parse.urlsplit(value).hostname or "" + + value = value.split("/", 1)[0] + value = value.split(":", 1)[0] + + return value.rstrip(".") + + +def is_valid_ipv4(value: str) -> bool: + try: + return isinstance(ipaddress.ip_address(value), ipaddress.IPv4Address) + except ValueError: + return False + + +def is_valid_ipv6(value: str) -> bool: + try: + return isinstance(ipaddress.ip_address(value), ipaddress.IPv6Address) + except ValueError: + return False + + +def dedupe(items: Iterable[str]) -> List[str]: + return sorted( + set( + x.strip() + for x in items + if x and x.strip() + ) + ) + + +# --------------------------------------------------------------------- +# Base58Check +# --------------------------------------------------------------------- + +BASE58_ALPHABET = ( + "123456789ABCDEFGHJKLMNPQRSTUVWXYZ" + "abcdefghijkmnopqrstuvwxyz" +) + + +def _base58_decode(value: str) -> bytes: + number = 0 + + for char in value: + number *= 58 + number += BASE58_ALPHABET.index(char) + + decoded = bytearray() + + while number: + decoded.append(number % 256) + number //= 256 + + leading_zeroes = 0 + + for char in value: + if char == "1": + leading_zeroes += 1 + else: + break + + return bytes( + b"\x00" * leading_zeroes + + bytes(reversed(decoded)) + ) + + +def validate_btc_base58(address: str) -> bool: + try: + if not BTC_BASE58_REGEX.fullmatch(address): + return False + + decoded = _base58_decode(address) + + if len(decoded) != 25: + return False + + version = decoded[0] + + if version not in (0x00, 0x05): + return False + + payload = decoded[:-4] + checksum = decoded[-4:] + + expected = hashlib.sha256( + hashlib.sha256(payload).digest() + ).digest()[:4] + + return checksum == expected + + except (ValueError, IndexError): + return False + + +# --------------------------------------------------------------------- +# Bech32 / Bech32m +# --------------------------------------------------------------------- + +BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def _bech32_polymod(values: Iterable[int]) -> int: + generators = [ + 0x3B6A57B2, + 0x26508E6D, + 0x1EA119FA, + 0x3D4233DD, + 0x2A1462B3, + ] + + checksum = 1 + + for value in values: + top = checksum >> 25 + + checksum = ( + (checksum & 0x1FFFFFF) << 5 + ) ^ value + + for index in range(5): + if (top >> index) & 1: + checksum ^= generators[index] + + return checksum + + +def _bech32_hrp_expand(hrp: str) -> List[int]: + return ( + [ord(char) >> 5 for char in hrp] + + [0] + + [ord(char) & 31 for char in hrp] + ) + + +def _bech32_decode( + value: str, +) -> Tuple[Optional[str], Optional[List[int]], Optional[str]]: + if not value: + return None, None, None + + if len(value) > 90: + return None, None, None + + if value.lower() != value and value.upper() != value: + return None, None, None + + value = value.lower() + + position = value.rfind("1") + + if position < 1: + return None, None, None + + if position + 7 > len(value): + return None, None, None + + data_part = value[position + 1:] + + if not all( + char in BECH32_CHARSET + for char in data_part + ): + return None, None, None + + hrp = value[:position] + + data = [ + BECH32_CHARSET.find(char) + for char in data_part + ] + + polymod = _bech32_polymod( + _bech32_hrp_expand(hrp) + data + ) + + if polymod == 1: + spec = "bech32" + elif polymod == 0x2BC830A3: + spec = "bech32m" + else: + return None, None, None + + return hrp, data[:-6], spec + + +def _convertbits( + data: Iterable[int], + frombits: int, + tobits: int, + pad: bool = True, +) -> Optional[List[int]]: + accumulator = 0 + bits = 0 + result: List[int] = [] + + max_value = (1 << tobits) - 1 + max_accumulator = ( + 1 << (frombits + tobits - 1) + ) - 1 + + for value in data: + if value < 0 or value >> frombits: + return None + + accumulator = ( + (accumulator << frombits) | value + ) & max_accumulator + + bits += frombits + + while bits >= tobits: + bits -= tobits + + result.append( + (accumulator >> bits) & max_value + ) + + if pad: + if bits: + result.append( + (accumulator << (tobits - bits)) + & max_value + ) + + elif bits >= frombits: + return None + + elif ( + (accumulator << (tobits - bits)) + & max_value + ): + return None + + return result + + +def validate_btc_bech32(address: str) -> bool: + try: + hrp, data, spec = _bech32_decode(address) + + if hrp != "bc" or data is None or spec is None: + return False + + if not data: + return False + + witness_version = data[0] + + if witness_version > 16: + return False + + program = _convertbits( + data[1:], + 5, + 8, + False, + ) + + if program is None: + return False + + if not 2 <= len(program) <= 40: + return False + + if witness_version == 0: + if spec != "bech32": + return False + + if len(program) not in (20, 32): + return False + + else: + if spec != "bech32m": + return False + + return True + + except Exception: + return False + + +def validate_btc_address(address: str) -> bool: + address = address.strip() + + if validate_btc_base58(address): + return True + + return validate_btc_bech32(address) + + +def btc_address_format(address: str) -> Optional[str]: + if not validate_btc_address(address): + return None + + if address.lower().startswith("bc1"): + return "Bech32 / SegWit" + + if address.startswith("1"): + return "Legacy P2PKH" + + if address.startswith("3"): + return "P2SH" + + return "Bitcoin" + + +# --------------------------------------------------------------------- +# Target detection +# --------------------------------------------------------------------- + + +def detect_target_type(target: str) -> Dict[str, Any]: + """ + Detect and normalize a target. + + This function remains compatible with the original SpectraScan API. + """ + + original = target or "" + + result: Dict[str, Any] = { + "input": original, + "normalized": normalize_target(original), + "type": "unknown", + "subtype": None, + "valid": False, + "confidence": 0, + "metadata": {}, + "warnings": [], + } + + if not original.strip(): + result["type"] = "empty" + return result + + normalized = result["normalized"] + plain = normalized + + # URL + if re.match(r"^https?://", plain, re.IGNORECASE): + parsed = urllib.parse.urlsplit(plain) + + hostname = parsed.hostname or "" + + if hostname.endswith(".onion"): + onion_type = ( + "v3" + if ONION_V3_REGEX.fullmatch(hostname) + else "v2" + if ONION_V2_REGEX.fullmatch(hostname) + else None + ) + + if onion_type: + result.update( + { + "type": "onion", + "subtype": onion_type, + "valid": True, + "confidence": 100, + "metadata": { + "onion": hostname.lower(), + "scheme": parsed.scheme.lower(), + "port": parsed.port, + "path": parsed.path or "/", + }, + } + ) + + if onion_type == "v2": + result["warnings"].append( + "Onion v2 is deprecated." + ) + + return result + + result.update( + { + "type": "url", + "valid": True, + "confidence": 98, + "metadata": { + "scheme": parsed.scheme.lower(), + "host": hostname.lower(), + "port": parsed.port, + "path": parsed.path or "/", + }, + } + ) + + return result + + # Onion hostname + onion_v3 = ONION_V3_REGEX.fullmatch(plain) + if onion_v3: + result.update( + { + "type": "onion", + "subtype": "v3", + "valid": True, + "confidence": 100, + "metadata": { + "onion": plain.lower(), + "length": 56, + }, + } + ) + return result + + onion_v2 = ONION_V2_REGEX.fullmatch(plain) + if onion_v2: + result.update( + { + "type": "onion", + "subtype": "v2", + "valid": True, + "confidence": 100, + "metadata": { + "onion": plain.lower(), + "length": 16, + }, + "warnings": [ + "Onion v2 is deprecated." + ], + } + ) + return result + + # Email + email_match = EMAIL_REGEX.fullmatch(plain) + + if email_match: + email = plain.lower() + domain = email.rsplit("@", 1)[1] + + result.update( + { + "type": "email", + "valid": True, + "confidence": 98, + "metadata": { + "email": email, + "domain": domain, + "disposable": ( + domain in DISPOSABLE_DOMAINS + ), + }, + } + ) + + if domain in DISPOSABLE_DOMAINS: + result["warnings"].append( + "Disposable email domain." + ) + + return result + + # IPv4 + if IPV4_REGEX.fullmatch(plain) and is_valid_ipv4(plain): + ip = ipaddress.ip_address(plain) + + result.update( + { + "type": "ipv4", + "valid": True, + "confidence": 99, + "metadata": { + "ip": str(ip), + "private": ip.is_private, + "loopback": ip.is_loopback, + "reserved": ip.is_reserved, + "global": ip.is_global, + }, + } + ) + + return result + + # IPv6 + if ":" in plain and is_valid_ipv6(plain): + ip = ipaddress.ip_address(plain) + + result.update( + { + "type": "ipv6", + "valid": True, + "confidence": 99, + "metadata": { + "ip": str(ip), + "private": ip.is_private, + "loopback": ip.is_loopback, + "reserved": ip.is_reserved, + "global": ip.is_global, + }, + } + ) + + return result + + # BTC + if validate_btc_address(plain): + result.update( + { + "type": "crypto", + "subtype": "btc", + "valid": True, + "confidence": 100, + "metadata": { + "format": btc_address_format(plain), + }, + } + ) + return result + + # ETH transaction hash / address + if ETH_TX_REGEX.fullmatch(plain): + result.update( + { + "type": "crypto", + "subtype": "eth_tx", + "valid": True, + "confidence": 99, + } + ) + return result + + if ETH_REGEX.fullmatch(plain): + result.update( + { + "type": "crypto", + "subtype": "eth", + "valid": True, + "confidence": 99, + "metadata": { + "format": "Ethereum address" + }, + } + ) + return result + + # XMR + if XMR_REGEX.fullmatch(plain): + result.update( + { + "type": "crypto", + "subtype": "xmr", + "valid": True, + "confidence": 75, + "metadata": { + "format": "Monero" + }, + "warnings": [ + "Monero validation is format-based." + ], + } + ) + return result + + # LTC + if LTC_REGEX.fullmatch(plain): + result.update( + { + "type": "crypto", + "subtype": "ltc", + "valid": True, + "confidence": 75, + "metadata": { + "format": "Litecoin" + }, + "warnings": [ + "Litecoin validation is format-based." + ], + } + ) + return result + + # Hashes + hash_types = ( + ("sha512", SHA512_REGEX), + ("sha256", SHA256_REGEX), + ("sha1", SHA1_REGEX), + ("md5", MD5_REGEX), + ) + + for name, pattern in hash_types: + if pattern.fullmatch(plain): + result.update( + { + "type": "hash", + "subtype": name, + "valid": True, + "confidence": 96, + } + ) + return result + + # PGP + if PGP_BEGIN_REGEX.search(original): + result.update( + { + "type": "pgp_key", + "valid": True, + "confidence": 100, + } + ) + return result + + # Phone + if PHONE_REGEX.fullmatch(plain): + digits = re.sub(r"\D", "", plain) + + if 7 <= len(digits) <= 15: + result.update( + { + "type": "phone", + "valid": True, + "confidence": 70, + "metadata": { + "digits": digits, + "length": len(digits), + }, + } + ) + return result + + # Domain + domain = normalize_domain(plain) + + if DOMAIN_REGEX.fullmatch(domain): + tld = domain.rsplit(".", 1)[-1] + + result.update( + { + "type": "domain", + "valid": True, + "confidence": 90, + "metadata": { + "domain": domain, + "tld": tld, + }, + } + ) + + if tld in SUSPICIOUS_TLDS: + result["warnings"].append( + f"Abuse-prone/suspicious TLD: .{tld}" + ) + + return result + + # Username + if USERNAME_REGEX.fullmatch(plain): + result.update( + { + "type": "username", + "valid": True, + "confidence": 45, + } + ) + + result["warnings"].append( + "Likely username; confirm through OSINT sources." + ) + + return result + + return result + + +# --------------------------------------------------------------------- +# IOC extraction +# --------------------------------------------------------------------- + + +def extract_iocs(text: str) -> Dict[str, Any]: + """ + Extract and normalize common public indicators from text. + """ + + text = text or "" + + urls = dedupe( + URL_REGEX.findall(text) + ) + + onions_v3 = dedupe( + m.group(0).lower() + for m in ONION_V3_REGEX.finditer(text) + ) + + onions_v2 = dedupe( + m.group(0).lower() + for m in ONION_V2_REGEX.finditer(text) + ) + + emails = dedupe( + m.group(0).lower() + for m in EMAIL_REGEX.finditer(text) + ) + + ipv4 = [] + + for match in IPV4_REGEX.finditer(text): + value = match.group(0) + + if is_valid_ipv4(value): + ipv4.append(value) + + ipv4 = dedupe(ipv4) + + ipv6 = [] + + for match in IPV6_REGEX.finditer(text): + value = match.group(0) + + if is_valid_ipv6(value): + ipv6.append(value) + + ipv6 = dedupe(ipv6) + + domains = [] + + for match in DOMAIN_REGEX.finditer(text): + value = match.group(0).lower() + + if not value.endswith(".onion"): + domains.append(value) + + domains = dedupe(domains) + + btc = [] + eth = [] + xmr = [] + ltc = [] + + tokens = re.findall( + r"[A-Za-z0-9]{20,100}", + text, + ) + + for token in tokens: + clean = token.strip(".,;:!?()[]{}<>\"'") + + if validate_btc_address(clean): + btc.append(clean) + + elif ETH_REGEX.fullmatch(clean): + eth.append(clean) + + elif XMR_REGEX.fullmatch(clean): + xmr.append(clean) + + elif LTC_REGEX.fullmatch(clean): + ltc.append(clean) + + hashes = { + "md5": [], + "sha1": [], + "sha256": [], + "sha512": [], + } + + for token in re.findall( + r"\b[a-fA-F0-9]{32,128}\b", + text, + ): + length = len(token) + + if length == 32: + hashes["md5"].append(token.lower()) + + elif length == 40: + hashes["sha1"].append(token.lower()) + + elif length == 64: + hashes["sha256"].append(token.lower()) + + elif length == 128: + hashes["sha512"].append(token.lower()) + + pgp_blocks = re.findall( + r"-----BEGIN PGP PUBLIC KEY BLOCK-----.*?" + r"-----END PGP PUBLIC KEY BLOCK-----", + text, + flags=re.IGNORECASE | re.DOTALL, + ) + + return { + "urls": dedupe(urls), + "onions": { + "v3": dedupe(onions_v3), + "v2": dedupe(onions_v2), + }, + "domains": dedupe(domains), + "ipv4": dedupe(ipv4), + "ipv6": dedupe(ipv6), + "emails": dedupe(emails), + "crypto": { + "btc": dedupe(btc), + "eth": dedupe(eth), + "xmr": dedupe(xmr), + "ltc": dedupe(ltc), + }, + "hashes": { + key: dedupe(value) + for key, value in hashes.items() + }, + "pgp_blocks": pgp_blocks, + "counts": { + "urls": len(urls), + "onions": len(onions_v3) + len(onions_v2), + "domains": len(domains), + "ipv4": len(ipv4), + "ipv6": len(ipv6), + "emails": len(emails), + "btc": len(btc), + "eth": len(eth), + "xmr": len(xmr), + "ltc": len(ltc), + "hashes": sum( + len(value) + for value in hashes.values() + ), + "pgp_blocks": len(pgp_blocks), + }, + } + + +def extract_onion_links(text: str) -> Dict[str, Any]: + iocs = extract_iocs(text) + + return { + "v3": iocs["onions"]["v3"], + "v2": iocs["onions"]["v2"], + "total": ( + len(iocs["onions"]["v3"]) + + len(iocs["onions"]["v2"]) + ), + } + + +# --------------------------------------------------------------------- +# Tor +# --------------------------------------------------------------------- + + +def _tor_socket( + timeout: float = DEFAULT_TIMEOUT, +): + if not SOCKS_AVAILABLE: + raise RuntimeError( + "PySocks is not installed." + ) + + sock = socks.socksocket() + + sock.set_proxy( + socks.PROXY_TYPE_SOCKS5, + TOR_SOCKS_HOST, + TOR_SOCKS_PORT, + rdns=True, + ) + + sock.settimeout(timeout) + + return sock + + +def check_tor_connection( + timeout: float = 15.0, +) -> Dict[str, Any]: + + result = { + "socks_reachable": False, + "tor_working": False, + "exit_ip": None, + "error": None, + "timestamp": utc_now(), + } + + if not SOCKS_AVAILABLE: + result["error"] = ( + "pysocks not installed." + ) + return result + + sock = None + + try: + sock = _tor_socket(timeout) + + sock.connect( + ("check.torproject.org", 443) + ) + + result["socks_reachable"] = True + + context = ssl.create_default_context() + + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + with context.wrap_socket( + sock, + server_hostname="check.torproject.org", + ) as secure: + + request = ( + "GET /api/ip HTTP/1.1\r\n" + "Host: check.torproject.org\r\n" + f"User-Agent: {USER_AGENT}\r\n" + "Connection: close\r\n" + "\r\n" + ) + + secure.sendall( + request.encode() + ) + + chunks = [] + + while True: + chunk = secure.recv(8192) + + if not chunk: + break + + chunks.append(chunk) + + raw = b"".join(chunks) + + if b"\r\n\r\n" in raw: + body = raw.split( + b"\r\n\r\n", + 1, + )[1] + + try: + payload = json.loads( + body.decode( + "utf-8", + errors="ignore", + ) + ) + + result["exit_ip"] = ( + payload.get("IP") + or payload.get("ip") + ) + + except Exception: + pass + + result["tor_working"] = True + + except Exception as exc: + result["error"] = ( + f"{type(exc).__name__}: {exc}" + ) + + finally: + if sock is not None: + try: + sock.close() + except Exception: + pass + + return result + + +# --------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------- + + +def _parse_http_headers( + header_bytes: bytes, +) -> Tuple[str, Optional[int], Dict[str, str]]: + + text = header_bytes.decode( + "iso-8859-1", + errors="replace", + ) + + lines = text.split("\r\n") + + status_line = lines[0] if lines else "" + + status_code = None + + parts = status_line.split() + + if len(parts) >= 2: + status_code = safe_int(parts[1]) + + headers: Dict[str, str] = {} + + for line in lines[1:]: + if ":" not in line: + continue + + key, value = line.split( + ":", + 1, + ) + + headers[key.strip().lower()] = ( + value.strip() + ) + + return ( + status_line, + status_code, + headers, + ) + + +def _extract_title( + body: bytes, +) -> Optional[str]: + + try: + text = body.decode( + "utf-8", + errors="ignore", + ) + + match = re.search( + r"]*>(.*?)", + text, + flags=re.IGNORECASE | re.DOTALL, + ) + + if not match: + return None + + title = html_mod.unescape( + match.group(1) + ) + + title = re.sub( + r"\s+", + " ", + title, + ).strip() + + return title[:500] or None + + except Exception: + return None + + +def _security_header_analysis( + headers: Dict[str, str], + scheme: str, +) -> Dict[str, Any]: + + present = {} + missing = [] + + for header, label in SECURITY_HEADERS.items(): + if header in headers: + present[label] = headers[header] + else: + missing.append(label) + + score = 100 + + for header in missing: + if header == "HSTS" and scheme == "https": + score -= 20 + elif header in ( + "CSP", + "X-Frame-Options", + "X-Content-Type-Options", + ): + score -= 15 + else: + score -= 5 + + if scheme != "https": + score -= 20 + + return { + "score": max(0, min(100, score)), + "present": present, + "missing": missing, + } + + +def _technology_fingerprint( + body: bytes, + headers: Dict[str, str], +) -> List[Dict[str, Any]]: + + text = body.decode( + "utf-8", + errors="ignore", + ) + + combined = ( + text[:200000] + + "\n" + + "\n".join( + f"{k}: {v}" + for k, v in headers.items() + ) + ) + + findings = [] + + for technology, signatures in ( + COMMON_TECH_SIGNATURES.items() + ): + matches = [] + + for signature in signatures: + try: + if re.search( + signature, + combined, + flags=re.IGNORECASE, + ): + matches.append(signature) + except re.error: + continue + + if matches: + findings.append( + { + "technology": technology, + "confidence": min( + 95, + 50 + len(matches) * 15, + ), + "evidence": matches, + } + ) + + return findings + + +# --------------------------------------------------------------------- +# TLS +# --------------------------------------------------------------------- + + +def _parse_tls_certificate( + der: bytes, +) -> Dict[str, Any]: + + info: Dict[str, Any] = { + "sha256_fp": hashlib.sha256( + der + ).hexdigest(), + + "sha1_fp": hashlib.sha1( + der + ).hexdigest(), + + "cert_size": len(der), + } + + if not CRYPTOGRAPHY_AVAILABLE: + info["parser"] = "openssl/native" + return info + + try: + certificate = ( + x509.load_der_x509_certificate( + der, + default_backend(), + ) + ) + + info["subject"] = ( + certificate.subject.rfc4514_string() + ) + + info["issuer"] = ( + certificate.issuer.rfc4514_string() + ) + + info["serial"] = str( + certificate.serial_number + ) + + info["version"] = ( + str(certificate.version) + ) + + try: + san = certificate.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ) + + info["san"] = [ + str(value) + for value in san.value + ] + + except Exception: + info["san"] = [] + + not_before = ( + certificate.not_valid_before_utc + if hasattr( + certificate, + "not_valid_before_utc", + ) + else certificate.not_valid_before.replace( + tzinfo=timezone.utc + ) + ) + + not_after = ( + certificate.not_valid_after_utc + if hasattr( + certificate, + "not_valid_after_utc", + ) + else certificate.not_valid_after.replace( + tzinfo=timezone.utc + ) + ) + + info["not_before"] = ( + not_before.isoformat() + ) + + info["not_after"] = ( + not_after.isoformat() + ) + + now = datetime.now(timezone.utc) + + days = ( + not_after - now + ).days + + info["days_to_expiry"] = days + info["expired"] = days < 0 + + info["self_signed"] = ( + certificate.subject + == certificate.issuer + ) + + except Exception as exc: + info["cert_error"] = str(exc) + + return info + + +# --------------------------------------------------------------------- +# Onion reconnaissance +# --------------------------------------------------------------------- + + +def _parse_onion_target( + target: str, + port: int = 443, + use_ssl: bool = True, +) -> Dict[str, Any]: + + target = target.strip() + + if "://" in target: + parsed = urllib.parse.urlsplit( + target + ) + + host = parsed.hostname + + if not host: + raise ValueError( + "Unable to parse hostname." + ) + + scheme = parsed.scheme.lower() + + use_ssl = scheme == "https" + + actual_port = ( + parsed.port + or (443 if use_ssl else 80) + ) + + path = parsed.path or "/" + + if parsed.query: + path += f"?{parsed.query}" + + return { + "host": host.lower(), + "port": actual_port, + "scheme": scheme, + "path": path, + } + + parsed = urllib.parse.urlsplit( + f"//{target}", + ) + + host = parsed.hostname + + if not host: + raise ValueError( + "Unable to parse hostname." + ) + + actual_port = ( + parsed.port + or port + ) + + path = parsed.path or "/" + + if parsed.query: + path += f"?{parsed.query}" + + return { + "host": host.lower(), + "port": actual_port, + "scheme": ( + "https" + if use_ssl + else "http" + ), + "path": path, + } + + +def grab_onion_banner( + onion: str, + port: int = 443, + use_ssl: bool = True, + timeout: float = DEFAULT_TIMEOUT, + max_body: int = MAX_HTTP_BODY, +) -> Dict[str, Any]: + + result: Dict[str, Any] = { + "target": onion, + "host": None, + "port": port, + "scheme": ( + "https" + if use_ssl + else "http" + ), + "reachable": False, + "tls": None, + "http": None, + "title": None, + "security": None, + "technologies": [], + "iocs": None, + "error": None, + "timestamp": utc_now(), + } + + if not SOCKS_AVAILABLE: + result["error"] = ( + "pysocks not installed." + ) + return result + + try: + parsed = _parse_onion_target( + onion, + port=port, + use_ssl=use_ssl, + ) + + host = parsed["host"] + actual_port = parsed["port"] + scheme = parsed["scheme"] + path = parsed["path"] + + result["host"] = host + result["port"] = actual_port + result["scheme"] = scheme + + if not ( + ONION_V3_REGEX.fullmatch(host) + or ONION_V2_REGEX.fullmatch(host) + ): + result["error"] = ( + f"Invalid onion hostname: {host}" + ) + return result + + sock = _tor_socket(timeout) + + try: + sock.connect( + (host, actual_port) + ) + + result["reachable"] = True + + stream = sock + + if scheme == "https": + context = ( + ssl.create_default_context() + ) + + context.check_hostname = False + context.verify_mode = ( + ssl.CERT_NONE + ) + + stream = context.wrap_socket( + sock, + server_hostname=host, + ) + + tls = { + "version": stream.version(), + "cipher": stream.cipher(), + } + + try: + der = ( + stream.getpeercert( + binary_form=True + ) + ) + + if der: + tls.update( + _parse_tls_certificate( + der + ) + ) + + except Exception as exc: + tls["cert_error"] = str( + exc + ) + + result["tls"] = tls + + request = ( + f"GET {path} HTTP/1.1\r\n" + f"Host: {host}\r\n" + f"User-Agent: {USER_AGENT}\r\n" + "Accept: text/html,application/xhtml+xml," + "application/xml;q=0.9,*/*;q=0.8\r\n" + "Connection: close\r\n" + "\r\n" + ) + + stream.sendall( + request.encode() + ) + + chunks = [] + total = 0 + + while total < max_body: + try: + chunk = stream.recv( + min(8192, max_body - total) + ) + except socket.timeout: + break + + if not chunk: + break + + chunks.append(chunk) + total += len(chunk) + + raw = b"".join(chunks) + + if b"\r\n\r\n" not in raw: + result["http"] = { + "raw_size": len(raw), + "error": ( + "No HTTP header delimiter." + ), + } + return result + + header_bytes, body = raw.split( + b"\r\n\r\n", + 1, + ) + + ( + status_line, + status_code, + headers, + ) = _parse_http_headers( + header_bytes + ) + + # Don't expose complete cookies. + safe_headers = dict(headers) + + if "set-cookie" in safe_headers: + safe_headers["set-cookie"] = ( + "[redacted]" + ) + + http_info = { + "status": status_line, + "status_code": status_code, + "headers": safe_headers, + "body_size": len(body), + "content_type": headers.get( + "content-type" + ), + "server": headers.get( + "server" + ), + "location": headers.get( + "location" + ), + } + + result["http"] = http_info + + result["title"] = _extract_title( + body + ) + + result["security"] = ( + _security_header_analysis( + headers, + scheme, + ) + ) + + result["technologies"] = ( + _technology_fingerprint( + body, + headers, + ) + ) + + result["iocs"] = extract_iocs( + body.decode( + "utf-8", + errors="ignore", + ) + ) + + finally: + try: + sock.close() + except Exception: + pass + + except Exception as exc: + result["error"] = ( + f"{type(exc).__name__}: {exc}" + ) + + return result + + +# --------------------------------------------------------------------- +# Redirect analysis +# --------------------------------------------------------------------- + + +def analyze_redirects( + target: str, + timeout: float = DEFAULT_TIMEOUT, + max_redirects: int = MAX_REDIRECTS, + through_tor: bool = False, +) -> Dict[str, Any]: + + result = { + "target": target, + "chain": [], + "final_url": None, + "redirect_count": 0, + "error": None, + } + + if not REQUESTS_AVAILABLE: + result["error"] = ( + "requests not installed." + ) + return result + + proxies = ( + { + "http": TOR_HTTP_PROXY, + "https": TOR_HTTP_PROXY, + } + if through_tor and SOCKS_AVAILABLE + else None + ) + + current = target + + for _ in range(max_redirects + 1): + try: + response = requests.get( + current, + timeout=timeout, + allow_redirects=False, + proxies=proxies, + headers={ + "User-Agent": USER_AGENT + }, + ) + + location = response.headers.get( + "location" + ) + + entry = { + "url": current, + "status_code": response.status_code, + "location": location, + } + + result["chain"].append(entry) + + if not location: + result["final_url"] = current + break + + current = urllib.parse.urljoin( + current, + location, + ) + + result["redirect_count"] += 1 + + except Exception as exc: + result["error"] = str(exc) + break + + return result + + +# --------------------------------------------------------------------- +# Domain / DNS intelligence +# --------------------------------------------------------------------- + + +def domain_intelligence( + domain: str, + timeout: float = DEFAULT_TIMEOUT, +) -> Dict[str, Any]: + + domain = normalize_domain(domain) + + result: Dict[str, Any] = { + "domain": domain, + "valid": bool( + DOMAIN_REGEX.fullmatch(domain) + ), + "records": {}, + "error": None, + "timestamp": utc_now(), + } + + if not result["valid"]: + result["error"] = ( + "Invalid domain." + ) + return result + + if not DNS_AVAILABLE: + result["error"] = ( + "dnspython not installed." + ) + return result + + record_types = ( + "A", + "AAAA", + "MX", + "NS", + "TXT", + "CNAME", + "SOA", + ) + + resolver = dns.resolver.Resolver() + resolver.timeout = timeout + resolver.lifetime = timeout + + for record_type in record_types: + try: + answers = resolver.resolve( + domain, + record_type, + ) + + values = [] + + for answer in answers: + values.append( + str(answer) + ) + + result["records"][ + record_type + ] = values + + except Exception: + result["records"][ + record_type + ] = [] + + txt_records = result[ + "records" + ].get("TXT", []) + + txt_joined = " ".join( + txt_records + ).lower() + + result["email_security"] = { + "spf": "v=spf1" in txt_joined, + "dmarc_hint": False, + } + + try: + dmarc = resolver.resolve( + f"_dmarc.{domain}", + "TXT", + ) + + result["email_security"][ + "dmarc_hint" + ] = any( + "v=dmarc1" + in str(record).lower() + for record in dmarc + ) + + except Exception: + pass + + return result + + +# --------------------------------------------------------------------- +# Email intelligence +# --------------------------------------------------------------------- + + +def email_intelligence( + email: str, + timeout: float = DEFAULT_TIMEOUT, +) -> Dict[str, Any]: + + email = email.strip().lower() + + detection = detect_target_type( + email + ) + + if detection["type"] != "email": + return { + "email": email, + "valid": False, + "error": "Invalid email address.", + } + + domain = detection[ + "metadata" + ]["domain"] + + result = { + "email": email, + "valid": True, + "domain": domain, + "disposable": ( + domain in DISPOSABLE_DOMAINS + ), + "domain_intelligence": None, + "timestamp": utc_now(), + } + + result["domain_intelligence"] = ( + domain_intelligence( + domain, + timeout=timeout, + ) + ) + + return result + + +# --------------------------------------------------------------------- +# Ahmia +# --------------------------------------------------------------------- + + +def ahmia_search( + query: str, + timeout: float = DEFAULT_TIMEOUT, +) -> Dict[str, Any]: + + if not REQUESTS_AVAILABLE: + return { + "error": ( + "requests not installed." + ) + } + + query = query.strip() + + if not query: + return { + "error": "Empty search query." + } + + try: + response = requests.get( + AHMIA_SEARCH + + urllib.parse.quote_plus(query), + timeout=timeout, + headers={ + "User-Agent": USER_AGENT + }, + ) + + if response.status_code != 200: + return { + "error": ( + f"Ahmia returned HTTP " + f"{response.status_code}" + ) + } + + links = extract_onion_links( + response.text + ) + + return { + "query": query, + "v3_links": links["v3"][:50], + "v2_links": links["v2"][:50], + "total": links["total"], + "source": "ahmia.fi", + "timestamp": utc_now(), + } + + except Exception as exc: + return { + "error": ( + f"Ahmia request failed: {exc}" + ) + } + + +# --------------------------------------------------------------------- +# BTC intelligence +# --------------------------------------------------------------------- + + +def btc_first_seen( + address: str, + timeout: float = DEFAULT_TIMEOUT, +) -> Dict[str, Any]: + + result: Dict[str, Any] = { + "address": address, + "valid": False, + "first_seen": None, + "first_seen_timestamp": None, + "first_seen_block": None, + "last_seen": None, + "tx_count": None, + "total_received_btc": None, + "total_sent_btc": None, + "balance_btc": None, + "age_days": None, + "age_years": None, + "source": None, + "error": None, + "timestamp": utc_now(), + } + + address = address.strip() + + if not validate_btc_address(address): + result["error"] = ( + "Invalid BTC address." + ) + return result + + result["valid"] = True + + if not REQUESTS_AVAILABLE: + result["error"] = ( + "requests not installed." + ) + return result + + proxies = ( + { + "http": TOR_HTTP_PROXY, + "https": TOR_HTTP_PROXY, + } + if SOCKS_AVAILABLE + else None + ) + + headers = { + "User-Agent": USER_AGENT + } + + def set_age(timestamp: int) -> None: + age_days = max( + 0, + int( + ( + time.time() + - timestamp + ) + / 86400 + ), + ) + + result["age_days"] = age_days + result["age_years"] = round( + age_days / 365.25, + 2, + ) + + # Blockchair + try: + response = requests.get( + f"{BLOCKCHAIR_API}/dashboards/address/" + f"{address}", + timeout=timeout, + proxies=proxies, + headers=headers, + ) + + if response.status_code == 200: + payload = response.json() + + data = payload.get( + "data", + {}, + ).get( + address, + {}, + ) + + address_meta = data.get( + "address", + {}, + ) + + chain = data.get( + "chain_stats", + {}, + ) + + first_seen = address_meta.get( + "first_seen_receiving" + ) + + if first_seen: + result["first_seen"] = ( + first_seen + ) + + try: + parsed = datetime.strptime( + first_seen, + "%Y-%m-%d %H:%M:%S", + ).replace( + tzinfo=timezone.utc + ) + + timestamp = int( + parsed.timestamp() + ) + + result[ + "first_seen_timestamp" + ] = timestamp + + set_age(timestamp) + + except Exception: + pass + + result["last_seen"] = ( + address_meta.get( + "last_seen_receiving" + ) + ) + + funded = ( + chain.get( + "funded_txo_sum" + ) + or 0 + ) + + spent = ( + chain.get( + "spent_txo_sum" + ) + or 0 + ) + + result["tx_count"] = ( + chain.get("tx_count") + ) + + result[ + "total_received_btc" + ] = funded / 1e8 + + result[ + "total_sent_btc" + ] = spent / 1e8 + + result[ + "balance_btc" + ] = (funded - spent) / 1e8 + + result["source"] = ( + "blockchair" + ) + + return result + + except Exception: + pass + + # Blockstream + try: + first_tx = None + cursor = None + + for _ in range(20): + url = ( + f"{BLOCKSTREAM_API}/address/" + f"{address}/txs" + ) + + if cursor: + url += ( + f"/chain/{cursor}" + ) + + response = requests.get( + url, + timeout=timeout, + proxies=proxies, + headers=headers, + ) + + if response.status_code != 200: + break + + transactions = response.json() + + if not transactions: + break + + first_tx = transactions[-1] + + if len(transactions) < 25: + break + + cursor = transactions[ + -1 + ].get("txid") + + if not cursor: + break + + if first_tx: + status = first_tx.get( + "status", + {}, + ) + + block_time = status.get( + "block_time" + ) + + if block_time: + result[ + "first_seen_timestamp" + ] = block_time + + result["first_seen"] = ( + datetime.fromtimestamp( + block_time, + timezone.utc, + ).isoformat() + ) + + set_age(block_time) + + result[ + "first_seen_block" + ] = status.get( + "block_height" + ) + + response = requests.get( + f"{BLOCKSTREAM_API}/address/" + f"{address}", + timeout=timeout, + proxies=proxies, + headers=headers, + ) + + if response.status_code == 200: + stats = response.json() + + chain = stats.get( + "chain_stats", + {}, + ) + + funded = ( + chain.get( + "funded_txo_sum" + ) + or 0 + ) + + spent = ( + chain.get( + "spent_txo_sum" + ) + or 0 + ) + + result["tx_count"] = ( + chain.get("tx_count") + ) + + result[ + "total_received_btc" + ] = funded / 1e8 + + result[ + "total_sent_btc" + ] = spent / 1e8 + + result[ + "balance_btc" + ] = (funded - spent) / 1e8 + + result["source"] = ( + "blockstream" + ) + + return result + + except Exception: + pass + + # Blockchain.info + try: + response = requests.get( + f"{BLOCKCHAIN_INFO}/rawaddr/" + f"{address}?limit=50", + timeout=timeout, + proxies=proxies, + headers=headers, + ) + + if response.status_code == 200: + data = response.json() + + transactions = data.get( + "txs", + [], + ) + + if transactions: + first_tx = transactions[-1] + + timestamp = first_tx.get( + "time" + ) + + if timestamp: + result[ + "first_seen_timestamp" + ] = timestamp + + result["first_seen"] = ( + datetime.fromtimestamp( + timestamp, + timezone.utc, + ).isoformat() + ) + + set_age(timestamp) + + result[ + "first_seen_block" + ] = ( + first_tx.get( + "block_index" + ) + or first_tx.get( + "block_height" + ) + ) + + result[ + "total_received_btc" + ] = ( + data.get( + "total_received", + 0, + ) + / 1e8 + ) + + result[ + "total_sent_btc" + ] = ( + data.get( + "total_sent", + 0, + ) + / 1e8 + ) + + result[ + "balance_btc" + ] = ( + data.get( + "final_balance", + 0, + ) + / 1e8 + ) + + result["tx_count"] = data.get( + "n_tx", + 0, + ) + + result["source"] = ( + "blockchain.info" + ) + + return result + + except Exception: + pass + + result["error"] = ( + "All BTC APIs failed." + ) + + return result + + +# --------------------------------------------------------------------- +# Risk scoring +# --------------------------------------------------------------------- + + +def score_btc_risk( + btc_data: Dict[str, Any], +) -> Dict[str, Any]: + + risk = { + "score": 0, + "level": "LOW", + "confidence": 50, + "factors": [], + } + + if not btc_data.get("valid"): + risk["level"] = "UNKNOWN" + risk["confidence"] = 0 + return risk + + age = btc_data.get( + "age_days" + ) + + tx_count = ( + btc_data.get("tx_count") + or 0 + ) + + balance = ( + btc_data.get("balance_btc") + or 0 + ) + + if age is not None: + if age < 1: + risk["score"] += 30 + risk["factors"].append( + "Address first observed less than 1 day ago." + ) + + elif age < 7: + risk["score"] += 15 + risk["factors"].append( + "Very recently observed address." + ) + + elif age < 30: + risk["score"] += 5 + risk["factors"].append( + "Recently observed address." + ) + + if tx_count == 0: + risk["score"] += 5 + risk["factors"].append( + "No observed transactions." + ) + + elif tx_count > 1000: + risk["score"] -= 10 + risk["factors"].append( + "High transaction volume; " + "could represent a service or exchange." + ) + + if balance > 10 and tx_count < 5: + risk["score"] += 10 + risk["factors"].append( + "High balance with low transaction count." + ) + + risk["score"] = max( + 0, + min( + 100, + risk["score"], + ), + ) + + if risk["score"] >= 60: + risk["level"] = "HIGH" + + elif risk["score"] >= 30: + risk["level"] = "MEDIUM" + + else: + risk["level"] = "LOW" + + risk["confidence"] = min( + 95, + 50 + len( + risk["factors"] + ) * 10, + ) + + return risk + + +def calculate_target_risk( + detection: Dict[str, Any], + recon: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + + score = 0 + factors: List[str] = [] + + if not detection.get("valid"): + return { + "score": 0, + "level": "UNKNOWN", + "confidence": 0, + "factors": [], + } + + target_type = detection.get( + "type" + ) + + warnings = detection.get( + "warnings", + [], + ) + + score += min( + len(warnings) * 5, + 20, + ) + + factors.extend( + warnings + ) + + if target_type == "onion": + score += 10 + factors.append( + "Target is a Tor onion service." + ) + + if detection.get( + "subtype" + ) == "v2": + score += 20 + factors.append( + "Deprecated onion v2 service." + ) + + elif target_type == "email": + if detection.get( + "metadata", + {}, + ).get("disposable"): + score += 25 + factors.append( + "Disposable email domain." + ) + + elif target_type == "domain": + tld = detection.get( + "metadata", + {}, + ).get("tld") + + if tld in SUSPICIOUS_TLDS: + score += 15 + factors.append( + f"Abuse-prone TLD: .{tld}" + ) + + if recon: + security = recon.get( + "security" + ) + + if security: + security_score = ( + security.get( + "score", + 100, + ) + ) + + if security_score < 50: + score += 15 + factors.append( + "Weak HTTP security-header posture." + ) + + tls = recon.get("tls") + + if tls and tls.get("expired"): + score += 20 + factors.append( + "Expired TLS certificate." + ) + + score = max( + 0, + min( + 100, + score, + ), + ) + + if score >= 60: + level = "HIGH" + elif score >= 30: + level = "MEDIUM" + else: + level = "LOW" + + return { + "score": score, + "level": level, + "confidence": min( + 95, + 50 + len(factors) * 8, + ), + "factors": factors, + } + + +# --------------------------------------------------------------------- +# Full target reconnaissance +# --------------------------------------------------------------------- + + +def full_recon( + target: str, + timeout: float = DEFAULT_TIMEOUT, + perform_network: bool = True, +) -> Dict[str, Any]: + + started = time.time() + + detection = detect_target_type( + target + ) + + report: Dict[str, Any] = { + "tool": "SpectraScan", + "module": MODULE_NAME, + "module_version": MODULE_VERSION, + "timestamp": utc_now(), + "target": target, + "detection": detection, + "recon": {}, + "iocs": None, + "risk": None, + "errors": [], + } + + if not detection.get("valid"): + report["risk"] = { + "score": 0, + "level": "UNKNOWN", + "confidence": 0, + "factors": [ + "Target could not be validated." + ], + } + + return report + + target_type = detection["type"] + subtype = detection.get("subtype") + + if not perform_network: + report["risk"] = ( + calculate_target_risk( + detection + ) + ) + return report + + try: + if target_type == "onion": + report["recon"] = ( + grab_onion_banner( + target, + timeout=timeout, + ) + ) + + elif target_type == "domain": + report["recon"] = ( + domain_intelligence( + target, + timeout=timeout, + ) + ) + + elif target_type == "url": + report["recon"] = ( + analyze_redirects( + target, + timeout=timeout, + ) + ) + + elif target_type == "email": + report["recon"] = ( + email_intelligence( + target, + timeout=timeout, + ) + ) + + elif ( + target_type == "crypto" + and subtype == "btc" + ): + btc = btc_first_seen( + target, + timeout=timeout, + ) + + report["recon"] = btc + + report["risk"] = ( + score_btc_risk(btc) + ) + + elif target_type in ( + "hash", + "username", + "phone", + "ipv4", + "ipv6", + "pgp_key", + ): + report["recon"] = { + "note": ( + "Target recognized. " + "No intrusive lookup performed." + ) + } + + except Exception as exc: + report["errors"].append( + f"{type(exc).__name__}: {exc}" + ) + + if report["risk"] is None: + report["risk"] = ( + calculate_target_risk( + detection, + report.get("recon"), + ) + ) + + report["duration_seconds"] = round( + time.time() - started, + 3, + ) + + return report + + +# --------------------------------------------------------------------- +# JSON reporting +# --------------------------------------------------------------------- + + +def save_json_report( + report: Dict[str, Any], + output_path: str, +) -> str: + + output_path = os.path.abspath( + os.path.expanduser( + output_path + ) + ) + + directory = os.path.dirname( + output_path + ) + + if directory: + os.makedirs( + directory, + exist_ok=True, + ) + + atomic_write_text(output_path, json.dumps(report, indent=2, ensure_ascii=False, default=str) + "\n") + return output_path + + +# --------------------------------------------------------------------- +# Display +# --------------------------------------------------------------------- + + +def display_detection( + detection: Dict[str, Any], +) -> None: + + if detection.get("type") == "empty": + console.print( + "[red]No target provided.[/red]" + ) + return + + target_type = detection.get( + "type", + "unknown", + ) + + if target_type == "unknown": + console.print( + Panel( + f"[red]Unknown target[/red]\n" + f"{detection.get('input', '')}", + title="Detection", + border_style="red", + ) + ) + return + + colors = { + "onion": "magenta", + "crypto": "yellow", + "email": "cyan", + "ipv4": "blue", + "ipv6": "blue", + "hash": "green", + "domain": "cyan", + "url": "cyan", + "phone": "white", + "username": "white", + "pgp_key": "green", + } + + color = colors.get( + target_type, + "white", + ) + + table = Table( + title="Target Detection", + border_style=color, + show_header=False, + ) + + table.add_column( + "Field", + style="bold", + ) + + table.add_column("Value") + + label = target_type.upper() + + if detection.get("subtype"): + label += ( + f" ({detection['subtype'].upper()})" + ) + + table.add_row( + "Type", + f"[{color}]{label}[/{color}]", + ) + + table.add_row( + "Confidence", + f"{detection.get('confidence', 0)}%", + ) + + table.add_row( + "Valid", + ( + "[green]YES[/green]" + if detection.get("valid") + else "[red]NO[/red]" + ), + ) + + if detection.get("normalized"): + table.add_row( + "Normalized", + detection["normalized"][:120], + ) + + for key, value in detection.get( + "metadata", + {}, + ).items(): + + if isinstance(value, list): + value = ", ".join( + str(x) + for x in value[:5] + ) + + table.add_row( + key, + str(value)[:120], + ) + + for warning in detection.get( + "warnings", + [], + ): + table.add_row( + "[yellow]![/yellow]", + f"[yellow]{warning}[/yellow]", + ) + + console.print(table) + + +def display_onion_banner( + banner: Dict[str, Any], +) -> None: + + if banner.get("error"): + console.print( + Panel( + f"[red]{banner['error']}[/red]", + title=( + f"Onion {banner.get('host', '?')}" + ), + border_style="red", + ) + ) + return + + table = Table( + title=( + f"Onion Recon — " + f"{banner.get('host', '?')}:" + f"{banner.get('port', '?')}" + ), + border_style="magenta", + show_header=False, + ) + + table.add_column( + "Field", + style="bold magenta", + ) + + table.add_column("Value") + + table.add_row( + "Reachable", + ( + "[green]YES[/green]" + if banner.get("reachable") + else "[red]NO[/red]" + ), + ) + + table.add_row( + "Scheme", + str( + banner.get( + "scheme", + "?", + ) + ).upper(), + ) + + if banner.get("title"): + table.add_row( + "Title", + banner["title"], + ) + + tls = banner.get("tls") + + if tls: + if tls.get("version"): + table.add_row( + "TLS", + str(tls["version"]), + ) + + if tls.get("cipher"): + table.add_row( + "Cipher", + str(tls["cipher"]), + ) + + if tls.get("sha256_fp"): + table.add_row( + "Certificate SHA-256", + tls["sha256_fp"], + ) + + if tls.get("subject"): + table.add_row( + "Subject", + tls["subject"][:100], + ) + + if tls.get("issuer"): + table.add_row( + "Issuer", + tls["issuer"][:100], + ) + + if tls.get("days_to_expiry") is not None: + days = tls[ + "days_to_expiry" + ] + + color = ( + "red" + if days < 0 + else "yellow" + if days < 30 + else "green" + ) + + table.add_row( + "Certificate Expiry", + f"[{color}]{days} days[/{color}]", + ) + + http = banner.get("http") + + if http: + status = http.get( + "status_code" + ) + + if status: + color = ( + "green" + if 200 <= status < 300 + else "yellow" + if 300 <= status < 400 + else "red" + ) + + table.add_row( + "HTTP", + f"[{color}]{status}[/{color}]", + ) + + if http.get("server"): + table.add_row( + "Server", + str( + http["server"] + )[:100], + ) + + if http.get("content_type"): + table.add_row( + "Content-Type", + str( + http["content_type"] + )[:100], + ) + + table.add_row( + "Body Size", + f"{http.get('body_size', 0):,} bytes", + ) + + security = banner.get( + "security" + ) + + if security: + table.add_row( + "Security Score", + f"{security.get('score', 0)}/100", + ) + + console.print(table) + + technologies = banner.get( + "technologies", + [], + ) + + if technologies: + tech_table = Table( + title="Technology Fingerprint", + border_style="cyan", + ) + + tech_table.add_column( + "Technology" + ) + + tech_table.add_column( + "Confidence" + ) + + for item in technologies: + tech_table.add_row( + item["technology"], + f"{item['confidence']}%", + ) + + console.print( + tech_table + ) + + iocs = banner.get("iocs") + + if iocs: + display_ioc_summary(iocs) + + +def display_ioc_summary( + iocs: Dict[str, Any], +) -> None: + + counts = iocs.get( + "counts", + {}, + ) + + table = Table( + title="IOC Summary", + border_style="green", + ) + + table.add_column("IOC") + table.add_column("Count") + + labels = ( + ("URLs", "urls"), + ("Onion", "onions"), + ("Domains", "domains"), + ("IPv4", "ipv4"), + ("IPv6", "ipv6"), + ("Emails", "emails"), + ("BTC", "btc"), + ("ETH", "eth"), + ("XMR", "xmr"), + ("LTC", "ltc"), + ("Hashes", "hashes"), + ("PGP", "pgp_blocks"), + ) + + for label, key in labels: + count = counts.get( + key, + 0, + ) + + if count: + table.add_row( + label, + str(count), + ) + + console.print(table) + + +def display_btc( + data: Dict[str, Any], + risk: Optional[Dict[str, Any]] = None, +) -> None: + + if not data.get("valid"): + console.print( + Panel( + f"[red]{data.get('error', 'Invalid BTC address')}[/red]", + title="BTC Intelligence", + border_style="red", + ) + ) + return + + table = Table( + title=( + f"BTC Intelligence — " + f"{data['address']}" + ), + border_style="yellow", + show_header=False, + ) + + table.add_column( + "Field", + style="bold yellow", + ) + + table.add_column("Value") + + if data.get("first_seen"): + table.add_row( + "First Seen", + str(data["first_seen"]), + ) + + if data.get("first_seen_block"): + table.add_row( + "First Block", + f"#{data['first_seen_block']:,}", + ) + + if data.get("age_days") is not None: + table.add_row( + "Age", + f"{data['age_days']:,} days " + f"(~{data.get('age_years', 0)} years)", + ) + + table.add_row( + "Transactions", + f"{data.get('tx_count') or 0:,}", + ) + + table.add_row( + "Received", + f"{data.get('total_received_btc') or 0:.8f} BTC", + ) + + table.add_row( + "Sent", + f"{data.get('total_sent_btc') or 0:.8f} BTC", + ) + + table.add_row( + "Balance", + f"{data.get('balance_btc') or 0:.8f} BTC", + ) + + table.add_row( + "Source", + str( + data.get( + "source", + "unknown", + ) + ), + ) + + console.print(table) + + if risk: + color = { + "HIGH": "red", + "MEDIUM": "yellow", + "LOW": "green", + }.get( + risk.get("level"), + "white", + ) + + console.print( + Panel( + f"Score: [bold]{risk.get('score', 0)}/100[/bold]\n" + f"Level: [{color}]" + f"{risk.get('level', 'UNKNOWN')}" + f"[/{color}]\n" + f"Confidence: " + f"{risk.get('confidence', 0)}%", + title="BTC Risk Heuristics", + border_style=color, + ) + ) + + for factor in risk.get( + "factors", + [], + ): + console.print( + f" [yellow]•[/yellow] {factor}" + ) + + +# --------------------------------------------------------------------- +# Menu helpers +# --------------------------------------------------------------------- + + +def _prompt( + text: str, + default: str = "", +) -> str: + + value = Prompt.ask( + f"[cyan]{text}[/cyan]", + default=default, + ) + + return value.strip() + + +def _save_report_interactive( + report: Dict[str, Any], +) -> None: + + path = _prompt( + "Output JSON path", + "spectrascan_darkweb_report.json", + ) + + try: + saved = save_json_report( + report, + path, + ) + + console.print( + f"[green]Report saved:[/green] " + f"{saved}" + ) + + except Exception as exc: + console.print( + f"[red]Unable to save report: " + f"{exc}[/red]" + ) + + +# --------------------------------------------------------------------- +# Interactive menu +# --------------------------------------------------------------------- + + +def run_darkweb_menu() -> None: + """ + Main Dark Web Intelligence interactive menu. + """ + + console.print( + Panel( + f"[bold magenta]" + f"SPECTRASCAN DARK WEB INTELLIGENCE" + f"[/bold magenta]\n" + f"[dim]Module {MODULE_VERSION}[/dim]", + border_style="magenta", + ) + ) + + if not SOCKS_AVAILABLE: + console.print( + "[yellow]![/yellow] " + "PySocks unavailable — " + "Tor operations disabled." + ) + + if not REQUESTS_AVAILABLE: + console.print( + "[yellow]![/yellow] " + "Requests unavailable — " + "HTTP/API operations disabled." + ) + + if not DNS_AVAILABLE: + console.print( + "[yellow]![/yellow] " + "dnspython unavailable — " + "DNS intelligence disabled." + ) + + while True: + console.print( + Panel( + """ +[bold magenta]DARK WEB INTELLIGENCE[/bold magenta] + +[green]1.[/green] Auto Detect Target +[green]2.[/green] Full Recon +[green]3.[/green] Onion Banner / Web Recon +[green]4.[/green] Extract IOCs +[green]5.[/green] BTC Intelligence +[green]6.[/green] Domain / DNS Intelligence +[green]7.[/green] Email Intelligence +[green]8.[/green] Ahmia Search +[green]9.[/green] Redirect Analysis +[green]10.[/green] Tor Connectivity +[green]11.[/green] Save JSON Report +[red]12.[/red] Exit + """, + border_style="magenta", + ) + ) + + choice = Prompt.ask( + "[bold magenta]Select option[/bold magenta]", + choices=[ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "10", + "11", + "12", + ], + default="1", + ) + + try: + # --------------------------------------------------------- + # Detection + # --------------------------------------------------------- + + if choice == "1": + target = _prompt( + "Target" + ) + + if target: + display_detection( + detect_target_type( + target + ) + ) + + # --------------------------------------------------------- + # Full recon + # --------------------------------------------------------- + + elif choice == "2": + target = _prompt( + "Target" + ) + + if not target: + continue + + with console.status( + "[magenta]Running full reconnaissance...[/magenta]" + ): + report = full_recon( + target + ) + + display_detection( + report["detection"] + ) + + if report[ + "detection" + ]["type"] == "onion": + display_onion_banner( + report["recon"] + ) + + elif ( + report[ + "detection" + ]["subtype"] + == "btc" + ): + display_btc( + report["recon"], + report["risk"], + ) + + else: + console.print( + Panel( + json.dumps( + report[ + "recon" + ], + indent=2, + default=str, + )[:10000], + title="Recon Result", + border_style="cyan", + ) + ) + + risk = report.get( + "risk" + ) + + if risk: + console.print( + Panel( + f"Risk: " + f"[bold]{risk.get('level')}[/bold]\n" + f"Score: " + f"{risk.get('score')}/100\n" + f"Confidence: " + f"{risk.get('confidence')}%", + title="Risk", + border_style=( + "red" + if risk.get( + "level" + ) == "HIGH" + else "yellow" + ), + ) + ) + + if Confirm.ask( + "Save JSON report?", + default=False, + ): + _save_report_interactive( + report + ) + + # --------------------------------------------------------- + # Onion + # --------------------------------------------------------- + + elif choice == "3": + if not SOCKS_AVAILABLE: + console.print( + "[red]PySocks required.[/red]" + ) + continue + + target = _prompt( + "Onion URL / hostname" + ) + + if not target: + continue + + with console.status( + "[magenta]Performing onion reconnaissance...[/magenta]" + ): + result = ( + grab_onion_banner( + target + ) + ) + + display_onion_banner( + result + ) + + # --------------------------------------------------------- + # IOC + # --------------------------------------------------------- + + elif choice == "4": + console.print( + "[dim]Paste text. " + "Press Enter twice to finish.[/dim]" + ) + + lines = [] + empty_lines = 0 + + while True: + try: + line = input() + except EOFError: + break + + if not line: + empty_lines += 1 + + if empty_lines >= 2: + break + + continue + + empty_lines = 0 + lines.append(line) + + iocs = extract_iocs( + "\n".join(lines) + ) + + display_ioc_summary( + iocs + ) + + if Confirm.ask( + "Show extracted values?", + default=True, + ): + console.print( + Panel( + json.dumps( + iocs, + indent=2, + ensure_ascii=False, + ), + title="Extracted IOCs", + border_style="green", + ) + ) + + # --------------------------------------------------------- + # BTC + # --------------------------------------------------------- + + elif choice == "5": + address = _prompt( + "BTC address" + ) + + if not address: + continue + + with console.status( + "[yellow]Querying public blockchain data...[/yellow]" + ): + data = btc_first_seen( + address + ) + + risk = ( + score_btc_risk(data) + if data.get("valid") + else None + ) + + display_btc( + data, + risk, + ) + + # --------------------------------------------------------- + # Domain + # --------------------------------------------------------- + + elif choice == "6": + domain = _prompt( + "Domain" + ) + + if not domain: + continue + + with console.status( + "[cyan]Resolving DNS records...[/cyan]" + ): + result = ( + domain_intelligence( + domain + ) + ) + + console.print( + Panel( + json.dumps( + result, + indent=2, + default=str, + ), + title="Domain Intelligence", + border_style="cyan", + ) + ) + + # --------------------------------------------------------- + # Email + # --------------------------------------------------------- + + elif choice == "7": + email = _prompt( + "Email" + ) + + if not email: + continue + + with console.status( + "[cyan]Analyzing email domain...[/cyan]" + ): + result = ( + email_intelligence( + email + ) + ) + + console.print( + Panel( + json.dumps( + result, + indent=2, + default=str, + ), + title="Email Intelligence", + border_style="cyan", + ) + ) + + # --------------------------------------------------------- + # Ahmia + # --------------------------------------------------------- + + elif choice == "8": + query = _prompt( + "Ahmia search" + ) + + if not query: + continue + + with console.status( + "[cyan]Searching Ahmia...[/cyan]" + ): + result = ( + ahmia_search( + query + ) + ) + + if result.get("error"): + console.print( + f"[red]{result['error']}[/red]" + ) + continue + + console.print( + f"[green]Found " + f"{result['total']} " + f".onion indicators.[/green]" + ) + + if result["v3_links"]: + console.print( + Panel( + "\n".join( + result[ + "v3_links" + ] + ), + title="Onion v3", + border_style="magenta", + ) + ) + + if result["v2_links"]: + console.print( + Panel( + "\n".join( + result[ + "v2_links" + ] + ), + title="Onion v2", + border_style="red", + ) + ) + + # --------------------------------------------------------- + # Redirects + # --------------------------------------------------------- + + elif choice == "9": + target = _prompt( + "HTTP/HTTPS URL" + ) + + if not target: + continue + + with console.status( + "[cyan]Analyzing redirects...[/cyan]" + ): + result = ( + analyze_redirects( + target + ) + ) + + table = Table( + title="Redirect Chain", + border_style="cyan", + ) + + table.add_column( + "#" + ) + + table.add_column( + "Status" + ) + + table.add_column( + "URL" + ) + + for index, item in enumerate( + result["chain"], + start=1, + ): + table.add_row( + str(index), + str( + item[ + "status_code" + ] + ), + item["url"][:150], + ) + + console.print( + table + ) + + if result.get( + "final_url" + ): + console.print( + f"[green]Final URL:[/green] " + f"{result['final_url']}" + ) + + if result.get("error"): + console.print( + f"[red]{result['error']}[/red]" + ) + + # --------------------------------------------------------- + # Tor + # --------------------------------------------------------- + + elif choice == "10": + with console.status( + "[magenta]Testing Tor SOCKS connectivity...[/magenta]" + ): + result = ( + check_tor_connection() + ) + + if result.get( + "tor_working" + ): + console.print( + Panel( + f"[green]Tor connection operational[/green]\n" + f"SOCKS: " + f"{TOR_SOCKS_HOST}:" + f"{TOR_SOCKS_PORT}\n" + f"Exit IP: " + f"[cyan]" + f"{result.get('exit_ip')}" + f"[/cyan]", + title="Tor", + border_style="green", + ) + ) + else: + console.print( + Panel( + f"[red]Tor unavailable[/red]\n" + f"{result.get('error')}", + title="Tor", + border_style="red", + ) + ) + + # --------------------------------------------------------- + # JSON report + # --------------------------------------------------------- + + elif choice == "11": + target = _prompt( + "Target" + ) + + if not target: + continue + + with console.status( + "[cyan]Building report...[/cyan]" + ): + report = full_recon( + target + ) + + _save_report_interactive( + report + ) + + # --------------------------------------------------------- + # Exit + # --------------------------------------------------------- + + elif choice == "12": + console.print( + "[dim]Leaving Dark Web Intelligence module.[/dim]" + ) + break + + except KeyboardInterrupt: + console.print( + "\n[yellow]Operation cancelled.[/yellow]" + ) + + except Exception as exc: + console.print( + f"[red]Error: " + f"{type(exc).__name__}: " + f"{exc}[/red]" + ) + + +# --------------------------------------------------------------------- +# Backwards compatibility +# --------------------------------------------------------------------- + + +def darkweb_menu() -> None: + return run_darkweb_menu() + + +# --------------------------------------------------------------------- +# Module entry point +# --------------------------------------------------------------------- + + + +# ============== Configuration ============== +COMMON_PORTS = { + 21: "FTP", + 22: "SSH", + 23: "Telnet", + 25: "SMTP", + 53: "DNS", + 80: "HTTP", + 110: "POP3", + 143: "IMAP", + 443: "HTTPS", + 445: "SMB", + 993: "IMAPS", + 995: "POP3S", + 1433: "MSSQL", + 1521: "Oracle", + 3306: "MySQL", + 3389: "RDP", + 5432: "PostgreSQL", + 5900: "VNC", + 6379: "Redis", + 8080: "HTTP-Alt", + 8443: "HTTPS-Alt", + 27017: "MongoDB", +} + +PORT_DESCRIPTIONS = { + 21: "File Transfer Protocol", + 22: "Secure Shell", + 23: "Telnet", + 25: "Mail Server", + 53: "Domain Name System", + 80: "Web Server", + 110: "Mail Retrieval", + 143: "Mail Access", + 443: "Secure Web", + 445: "SMB/CIFS", + 993: "Secure IMAP", + 995: "Secure POP", + 1433: "MS SQL Server", + 1521: "Oracle DB", + 3306: "MySQL Server", + 3389: "Remote Desktop", + 5432: "PostgreSQL", + 5900: "VNC", + 6379: "Redis", + 8080: "Alt HTTP", + 8443: "Alt HTTPS", + 27017: "MongoDB", +} + +VULNERABILITIES = { + 21: ["Anonymous FTP enabled", "FTP cleartext transmission"], + 22: ["SSH brute-force possible", "Outdated SSH version"], + 23: ["Telnet unencrypted", "No authentication required"], + 25: ["Open SMTP relay", "Mail server misconfiguration"], + 80: ["HTTP methods enabled", "Directory listing possible"], + 443: ["SSL/TLS vulnerabilities", "Outdated certificates"], + 445: ["SMB vulnerabilities", "EternalBlue (MS17-010)"], + 3306: ["MySQL exposed", "Default credentials possible"], + 3389: ["RDP vulnerabilities", "BlueKeep (CVE-2019-0708)"], + 8080: ["Proxy misconfiguration", "Debug endpoints exposed"], +} + +TIMING_PROFILES = { + "T0": {"threads": 1, "timeout": 5.0, "delay": 300, "name": "Paranoid"}, + "T1": {"threads": 5, "timeout": 3.0, "delay": 100, "name": "Sneaky"}, + "T2": {"threads": 10, "timeout": 2.0, "delay": 50, "name": "Polite"}, + "T3": {"threads": 50, "timeout": 1.0, "delay": 10, "name": "Normal"}, + "T4": {"threads": 100, "timeout": 0.5, "delay": 0, "name": "Aggressive"}, + "T5": {"threads": 200, "timeout": 0.2, "delay": 0, "name": "Insane"}, +} + +COMMON_SUBDOMAINS = [ + "www", "mail", "ftp", "admin", "blog", "dev", "test", "api", "cdn", + "smtp", "pop", "imap", "ssh", "vpn", "cloud", "backup", "staging", + "demo", "old", "new", +] + +HTTP_PATHS = [ + "/", "/admin", "/login", "/wp-admin", "/phpmyadmin", "/api", "/api-docs", + "/swagger", "/console", "/manager", "/backup", "/wp-login.php", + "/admin/login", "/administrator", "/.env", "/config", "/config.php", + "/settings", "/dashboard", "/robots.txt", "/sitemap.xml", "/.git/config", + "/.htaccess", +] + +# Colors +GREEN = "\033[92m" +RED = "\033[91m" +YELLOW = "\033[93m" +CYAN = "\033[96m" +BLUE = "\033[94m" +MAGENTA = "\033[95m" +RESET = "\033[0m" +BOLD = "\033[1m" + +# ============== Report Manager ============== +class ReportManager: + """Console report manager backed by the shared atomic report store.""" + def __init__(self): + self.store = ReportStore() + self.report_dir = str(self.store.directory) + self.current_file = None + self.append_file = False + self._buffer: List[str] = [] + + def save_output(self): + if not self._buffer: + console.print("[yellow]No report output to save.[/yellow]") + return None + filename = f"SS-report-{datetime.now().strftime('%Y%m%d-%H%M%S')}.txt" + path = self.store.save_text(filename, "\n".join(self._buffer) + "\n") + console.print(f"[green][+] Report saved: {path}[/green]") + return path + + def write(self, text): + value = str(text) + self._buffer.append(value) + console.print(value, markup=False) + + def read_report(self): + reports = self.store.list_reports() + if not reports: + console.print("[yellow]No reports found.[/yellow]") + return + for index, filename in enumerate(reports, 1): + console.print(f"{index}. {filename}") + try: + selection = int(input("Select report number: ").strip()) + if not 1 <= selection <= len(reports): + raise ValueError + console.print(self.store.read_text(reports[selection - 1]), markup=False) + except (ValueError, OSError, UnicodeError): + console.print("[red]Invalid selection or report could not be read.[/red]") + + def delete_report(self): + reports = self.store.list_reports() + if not reports: + console.print("[yellow]No reports found.[/yellow]") + return + for index, filename in enumerate(reports, 1): + console.print(f"{index}. {filename}") + try: + selection = int(input("Select report number to delete: ").strip()) + if not 1 <= selection <= len(reports): + raise ValueError + filename = reports[selection - 1] + if Confirm.ask(f"Delete {filename}?", default=False): + self.store.delete(filename) + console.print("[green][+] Deleted.[/green]") + except (ValueError, OSError): + console.print("[red]Invalid selection or report could not be deleted.[/red]") + + +# ============== Utility Functions ============== +def normalize_target(target: str) -> str: + """Normalize an IP, CIDR, hostname, host:port, or HTTP(S) URL.""" + if not target: + return "" + + target = target.strip() + target = target.strip("\"'") + + if not target: + return "" + + try: + ipaddress.ip_network(target, strict=False) + return target + except ValueError: + pass + + if "://" in target: + try: + parsed = urllib.parse.urlparse(target) + if parsed.hostname: + return parsed.hostname + except Exception: + pass + + try: + if target.startswith("["): + closing = target.find("]") + if closing != -1: + return target[1:closing] + + parsed = urllib.parse.urlparse("//" + target) + if parsed.hostname: + return parsed.hostname + except Exception: + pass + + return target.rstrip("/") + + +def resolve_host(hostname: str) -> str: + """Resolve a single host/IP. CIDR networks are handled separately.""" + target = normalize_target(hostname) + + if not target: + raise ValueError("Target cannot be empty") + + try: + network = ipaddress.ip_network(target, strict=False) + raise ValueError(f"CIDR network detected: {network}") + except ValueError as exc: + if "CIDR network detected" in str(exc): + raise + + try: + return socket.gethostbyname(target) + except socket.gaierror as exc: + raise ValueError(f"Cannot resolve target: {target}") from exc + +def reverse_dns(ip: str) -> str: + """Reverse DNS lookup""" + try: + return socket.gethostbyaddr(ip) + except: + return "Unknown" + +def get_local_ip() -> str: + """Get local IP address""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname() + s.close() + return local_ip + except: + return "127.0.0.1" + +def generate_decoys(count: int) -> list: + """Generate random decoy IP addresses""" + decoys = [] + for _ in range(count): + ip_type = random.randint(1, 3) + if ip_type == 1: + decoy = f"10.{random.randint(1,255)}.{random.randint(1,255)}.{random.randint(1,255)}" + elif ip_type == 2: + decoy = f"172.{random.randint(16,31)}.{random.randint(1,255)}.{random.randint(1,255)}" + else: + decoy = f"192.168.{random.randint(1,255)}.{random.randint(1,255)}" + decoys.append(decoy) + return decoys + +def grab_banner(ip: str, port: int, timeout: float = 3.0) -> str: + """Grab banner from service""" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((ip, port)) + probes = { + 22: b"SSH-2.0-OpenSSH_8.0\r\n", + 80: b"HEAD / HTTP/1.0\r\n\r\n", + 443: b"HEAD / HTTP/1.0\r\n\r\n", + } + if port in probes: + sock.send(probes[port]) + banner = sock.recv(1024).decode("utf-8", errors="ignore").strip() + sock.close() + return banner if banner else "" + except: + return "" + +def identify_service(banner: str) -> Dict: + """Identify service from banner""" + if not banner: + return {"type": "unknown", "version": "unknown"} + signatures = { + "SSH": ["SSH", "OpenSSH", "Dropbear"], + "FTP": ["220", "FTP", "vsftpd", "ProFTPD"], + "HTTP": ["HTTP", "Apache", "nginx", "IIS"], + "MySQL": ["mysql", "MariaDB"], + "Redis": ["REDIS", "-ERR"], + } + for service, sigs in signatures.items(): + for sig in sigs: + if sig.lower() in banner.lower(): + ver = re.search(r"(\d+\.\d+(?:\.\d+)?)", banner) + return {"type": service, "version": ver.group(1) if ver else "unknown"} + return {"type": "unknown", "version": "unknown"} + +def check_vulnerabilities(port: int, service: str, banner: str = "") -> List[Dict]: + """Check for known vulnerabilities""" + vulns = [] + if port in VULNERABILITIES: + for vuln in VULNERABILITIES[port]: + severity = ( + "HIGH" + if any(k in vuln.lower() for k in ["unencrypted", "exposed", "vuln"]) + else "MEDIUM" + ) + vulns.append( + { + "port": port, + "service": service, + "vulnerability": vuln, + "severity": severity, + "cve": "N/A", + } + ) + return vulns + +# ============== Rate Limiter ============== +class RateLimiter: + __slots__ = ("pps", "delay", "last_request") + + def __init__(self, packets_per_second: int = 100): + self.pps = packets_per_second + self.delay = 1.0 / packets_per_second if packets_per_second > 0 else 0 + self.last_request = 0 + + def wait(self): + if self.delay > 0: + elapsed = time.time() - self.last_request + if elapsed < self.delay: + time.sleep(self.delay - elapsed) + self.last_request = time.time() + +# ============== SYN Scanner ============== +class SYNScan: + __slots__ = ("is_admin",) + + def __init__(self): + self.is_admin = os.geteuid() == 0 if hasattr(os, "geteuid") else False + + def create_packet(self, src_ip: str, dst_ip: str, dst_port: int) -> bytes: + ip_header = struct.pack( + "!BBHHHBBH4s4s", + 0x45, + 0, + 40, + random.randint(1, 65535), + 0, + 64, + 6, + 0, + socket.inet_aton(src_ip), + socket.inet_aton(dst_ip), + ) + tcp_header = struct.pack( + "!HHLLBBHHH", + random.randint(1024, 65535), + dst_port, + random.randint(0, 4294967295), + 0, + 0x50, + 0x02, + 65535, + 0, + 0, + ) + return ip_header + tcp_header + + def scan(self, target_ip: str, port: int, timeout: float = 2.0) -> str: + # Fall back to TCP connect scan if we don't have raw socket privileges + if not self.is_admin: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + rc = sock.connect_ex((target_ip, port)) + sock.close() + if rc == 0: + return "open" + elif rc in (111, 10061): # ECONNREFUSED + return "closed" + else: + return "filtered" + except socket.timeout: + return "filtered" + except: + return "error" + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) + sock.settimeout(timeout) + packet = self.create_packet(get_local_ip(), target_ip, port) + sock.sendto(packet, (target_ip, 0)) + data, _ = sock.recvfrom(1024) + sock.close() + tcp_flags = data[20 + 13] + if tcp_flags & 0x12: + return "open" + elif tcp_flags & 0x14 or tcp_flags & 0x04: + return "closed" + return "filtered" + except: + return "error" + +# ============== UDP Scanner ============== +class UDPScan: + __slots__ = ("common_udp_ports",) + + def __init__(self): + self.common_udp_ports = { + 53: ("DNS", "DNS Query"), + 67: ("DHCP", "DHCP Server"), + 68: ("DHCP", "DHCP Client"), + 69: ("TFTP", "Trivial FTP"), + 123: ("NTP", "Network Time Protocol"), + 161: ("SNMP", "Simple Network Management"), + 162: ("SNMPTRAP", "SNMP Trap"), + 389: ("LDAP", "Lightweight Directory Access"), + 500: ("ISAKMP", "Internet Security"), + 514: ("Syslog", "System Logging"), + 520: ("RIP", "Routing Information"), + 1194: ("OpenVPN", "VPN"), + 1701: ("L2TP", "Layer 2 Tunneling"), + 1812: ("RADIUS", "Authentication"), + 1813: ("RADIUS", "Accounting"), + 4500: ("IPSec", "NAT Traversal"), + } + + def scan(self, target_ip: str, port: int, timeout: float = 3.0) -> Dict: + svc_tuple = self.common_udp_ports.get(port, ("unknown", "")) + result = { + "port": port, + "protocol": "udp", + "state": "open|filtered", + "service": svc_tuple, + "info": svc_tuple, + } + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(timeout) + probes = { + 53: b"\x00\x00\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x06google\x03com\x00\x00\x01\x00\x01", + 161: b"\x30\x00\x00\x00\x02\x01\x00\x04\x06public\xa0\x1f\x02\x01\x00\x02\x01\x00\x30\x14", + } + sock.sendto(probes.get(port, b"\x00"), (target_ip, port)) + try: + data, _ = sock.recvfrom(1024) + result["state"] = "open" + result["response"] = data.hex()[:100] + except socket.timeout: + result["state"] = "open|filtered" + sock.close() + except socket.error as e: + result["state"] = "error" + result["error"] = str(e) + return result + +# ============== OS Fingerprinting ============== +class OSFingerprint: + @staticmethod + def get_ttl(ip: str) -> Optional[int]: + try: + cmd = ( + ["ping", "-n", "1", "-w", "100", ip] + if sys.platform == "win32" + else ["ping", "-c", "1", "-W", "2", ip] + ) + output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode() + match = re.search(r"ttl=(\d+)", output, re.IGNORECASE) + return int(match.group(1)) if match else None + except: + return None + + @staticmethod + def detect_os(ip: str) -> Dict: + result = {"os": "Unknown", "confidence": 0, "ttl": None, "details": []} + ttl = OSFingerprint.get_ttl(ip) + if ttl: + result["ttl"] = ttl + result["details"].append(f"TTL: {ttl}") + if ttl <= 64: + result["os"] = "Linux/Unix/MacOS" + result["confidence"] = 70 + elif ttl <= 128: + result["os"] = "Windows" + result["confidence"] = 70 + else: + result["os"] = "Network Device/Unix" + result["confidence"] = 50 + try: + start = time.time() + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) + sock.connect((ip, 80)) + sock.close() + response_time = time.time() - start + result["details"].append(f"Response: {response_time*1000:.1f}ms") + result["details"].append( + "Fast response: Likely Linux/Unix" + if response_time < 0.1 + else "Slow response: Possibly Windows" + ) + except: + pass + return result + +# ============== SSL/TLS Analyzer ============== +class SSLAnalyzer: + @staticmethod + def analyze(ip: str, port: int = 443, timeout: float = 5.0) -> Dict: + result = { + "supports_ssl": False, + "versions": [], + "cipher_suites": [], + "certificate": {}, + "vulnerabilities": [], + "grade": "N/A", + } + try: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + with socket.create_connection((ip, port), timeout=timeout) as sock: + with context.wrap_socket(sock, server_hostname=ip) as ssock: + result["supports_ssl"] = True + result["versions"] = ssock.version() + if ssock.cipher(): + result["certificate"] = {"cipher": ssock.cipher()} + version = ssock.version() + if version in ["SSLv2", "SSLv3"]: + result["vulnerabilities"].append( + f"Deprecated {version} protocol" + ) + result["grade"] = ( + "A+" + if "TLSv1.3" in str(result["versions"]) + else ( + "A" + if "TLSv1.2" in str(result["versions"]) + else "C" if "TLSv1.0" in str(result["versions"]) else "F" + ) + ) + except: + pass + return result + +# ============== HTTP Enumerator ============== +class HTTPEnumerator: + @staticmethod + def get_headers(ip: str, port: int = 80, use_ssl: bool = False) -> Dict: + result = { + "server": "Unknown", + "content_type": None, + "security_headers": {}, + "methods": [], + "status_code": None, + } + protocol = "https" if use_ssl else "http" + url = f"{protocol}://{ip}:{port}/" + try: + req = Request(url, method="GET") + req.add_header("User-Agent", "Mozilla/5.0 (Port Scanner)") + with urlopen(req, timeout=5) as response: + result["status_code"] = response.status + result["server"] = response.headers.get("Server", "Unknown") + result["content_type"] = response.headers.get("Content-Type") + result["security_headers"] = { + "X-Frame-Options": response.headers.get("X-Frame-Options"), + "X-Content-Type": response.headers.get("X-Content-Type-Options"), + "CSP": response.headers.get("Content-Security-Policy"), + "HSTS": response.headers.get("Strict-Transport-Security"), + } + except: + pass + return result + + @staticmethod + def check_methods(ip: str, port: int = 80, use_ssl: bool = False) -> List[str]: + methods = [] + protocol = "https" if use_ssl else "http" + url = f"{protocol}://{ip}:{port}/" + http_methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"] + for method in http_methods: + try: + req = Request(url, method=method) + req.add_header("User-Agent", "Port Scanner") + with urlopen(req, timeout=3) as response: + if response.status and response.status < 400: + methods.append(method) + except: + pass + return methods + + @staticmethod + def enumerate_paths(ip: str, port: int = 80, use_ssl: bool = False) -> List[Dict]: + found = [] + protocol = "https" if use_ssl else "http" + for path in HTTP_PATHS: + url = f"{protocol}://{ip}:{port}{path}" + try: + req = Request(url, method="GET") + req.add_header("User-Agent", "Port Scanner") + with urlopen(req, timeout=3) as response: + found.append( + { + "path": path, + "status": response.status, + "size": response.headers.get("Content-Length", "Unknown"), + } + ) + except HTTPError as e: + found.append({"path": path, "status": e.code, "size": 0}) + except: + pass + return found + +# ============== DNS Enumerator ============== +class DNSEnumerator: + @staticmethod + def get_records(domain: str) -> Dict: + result = {"A": [], "AAAA": [], "MX": [], "NS": [], "TXT": [], "CNAME": []} + try: + import dns.resolver + for rtype in ["A", "AAAA", "MX", "NS", "TXT", "CNAME"]: + try: + answers = dns.resolver.resolve(domain, rtype) + for rdata in answers: + if rtype == "MX": + result["MX"].append( + f"{rdata.exchange} (pref: {rdata.preference})" + ) + else: + result[rtype].append(str(rdata)) + except: + pass + except ImportError: + try: + ip = socket.gethostbyname(domain) + result["A"].append(ip) + except: + pass + except: + pass + return result + + @staticmethod + def enumerate_subdomains(domain: str, wordlist: List[str] = None) -> List[str]: + found = [] + subdomains = wordlist or COMMON_SUBDOMAINS + for sub in subdomains: + hostname = f"{sub}.{domain}" + try: + ip = socket.gethostbyname(hostname) + found.append({"subdomain": hostname, "ip": ip}) + except: + pass + return found + +# ============== Service Enumerator ============== +class ServiceEnumerator: + @staticmethod + def enumerate_ftp(ip: str, port: int = 21) -> Dict: + result = {"anonymous": False, "features": [], "version": None} + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((ip, port)) + banner = sock.recv(1024).decode("utf-8", errors="ignore").strip() + result["version"] = banner + sock.send(b"USER anonymous\r\n") + resp = sock.recv(1024).decode("utf-8", errors="ignore") + if "331" in resp: + sock.send(b"PASS anonymous@example.com\r\n") + resp = sock.recv(1024).decode("utf-8", errors="ignore") + if "230" in resp: + result["anonymous"] = True + sock.send(b"FEAT\r\n") + resp = sock.recv(4096).decode("utf-8", errors="ignore") + if "211" in resp: + result["features"] = resp.split("\n")[1:-1] + sock.close() + except Exception as e: + result["error"] = str(e) + return result + + @staticmethod + def enumerate_ssh(ip: str, port: int = 22) -> Dict: + result = {"version": None, "algorithms": {}} + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((ip, port)) + banner = sock.recv(256).decode("utf-8", errors="ignore").strip() + result["version"] = banner + if "OpenSSH" in banner: + match = re.search(r"OpenSSH_([\d.]+)", banner) + if match: + result["version"] = f"OpenSSH {match.group(1)}" + sock.close() + except Exception as e: + result["error"] = str(e) + return result + + @staticmethod + def enumerate_smb(ip: str, port: int = 445) -> Dict: + result = {"version": None, "signing": None, "shares": []} + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((ip, port)) + sock.close() + except: + pass + return result + +# ============== Firewall Detector ============== +class FirewallDetector: + @staticmethod + def detect(ip: str, port: int = 80) -> Dict: + result = {"has_firewall": "Unknown", "evidence": [], "stealth_score": 0} + try: + if sys.platform == "win32": + cmd = ["ping", "-n", "1", "-w", "100", ip] + else: + cmd = ["ping", "-c", "1", "-W", "1", ip] + output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode() + if "TTL=" in output or "ttl=" in output: + result["evidence"].append("Host responds to ICMP") + else: + result["evidence"].append("No ICMP response - possible firewall") + result["stealth_score"] += 30 + except: + result["evidence"].append("ICMP blocked - likely firewall") + result["stealth_score"] += 40 + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) + sock.connect((ip, port)) + sock.close() + result["evidence"].append("TCP connection successful") + except: + result["evidence"].append("TCP connection blocked") + result["stealth_score"] += 20 + if result["stealth_score"] >= 50: + result["has_firewall"] = "Likely" + elif result["stealth_score"] >= 30: + result["has_firewall"] = "Possible" + else: + result["has_firewall"] = "Unlikely" + return result + +# ============== Ping Sweep ============== +class PingSweep: + @staticmethod + def sweep(network: str, timeout: float = 1.0) -> List[Dict]: + hosts = [] + try: + net = ipaddress.ip_network(network, strict=False) + print(f"{CYAN}[*] Scanning {network} for live hosts...") + def ping_host(ip): + try: + cmd = ( + ["ping", "-n", "1", "-w", str(int(timeout * 1000)), str(ip)] + if sys.platform == "win32" + else ["ping", "-c", "1", "-W", str(int(timeout)), str(ip)] + ) + result = subprocess.run( + cmd, capture_output=True, timeout=timeout + 1 + ) + if result.returncode == 0: + return {"ip": str(ip), "alive": True} + except: + pass + return {"ip": str(ip), "alive": False} + if net.num_addresses > 4096: + raise ValueError(f"Network contains {net.num_addresses} addresses; the ping-sweep hard limit is 4096.") + if net.num_addresses > 256 and not Confirm.ask(f"Scan {net.num_addresses} addresses?", default=False): + print(f"{YELLOW}[*] Ping sweep cancelled.{RESET}") + return hosts + with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor: + iterator = iter(net.hosts()) + pending = set() + for _ in range(min(100, net.num_addresses)): + try: + pending.add(executor.submit(ping_host, next(iterator))) + except StopIteration: + break + while pending: + done, pending = concurrent.futures.wait(pending, return_when=concurrent.futures.FIRST_COMPLETED) + for future in done: + res = future.result() + if res["alive"]: + hosts.append(res) + print(f"{GREEN}[+] Host found: {res['ip']}{RESET}") + try: + pending.add(executor.submit(ping_host, next(iterator))) + except StopIteration: + pass + except Exception as e: + print(f"{RED}[-] Error: {e}{RESET}") + return hosts + +# ============== ARP Scanner ============== +class ARPScanner: + @staticmethod + def scan(network: str) -> List[Dict]: + hosts = [] + try: + # Determine the network prefix (e.g., "192.168.1.") for membership testing + try: + net = ipaddress.ip_network(network, strict=False) + net_hosts = {str(ip) for ip in net.hosts()} + except ValueError: + net_hosts = None + + cmd = ["arp", "-a"] if sys.platform == "win32" else ["arp", "-a", "-n"] + output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode() + for line in output.split("\n"): + match = re.search( + r"(\d+\.\d+\.\d+\.\d+)\s+([0-9a-f:-]+)\s+(\w+)", + line, + re.IGNORECASE, + ) + if match: + ip, mac, iface = match.groups() + if net_hosts is None: + if ip.startswith(".".join(network.split(".")[:3]) + "."): + hosts.append({"ip": ip, "mac": mac, "interface": iface}) + else: + if ip in net_hosts: + hosts.append({"ip": ip, "mac": mac, "interface": iface}) + except Exception as e: + print(f"{RED}[-] ARP scan error: {e}{RESET}") + return hosts + +# ============== Traceroute ============== +def traceroute(target: str, max_hops: int = 30) -> List[Dict]: + hops = [] + print(f"{CYAN}[*] Traceroute to {target}...") + for ttl in range(1, max_hops + 1): + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(2.0) + sock.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, ttl) + sock.sendto(b"traceroute", (target, 33434 + ttl)) + data, addr = sock.recvfrom(512) + hop_ip = addr if isinstance(addr, tuple) else addr + hops.append({"ttl": ttl, "ip": hop_ip, "hostname": reverse_dns(hop_ip)}) + sock.close() + if hop_ip == target: + break + except socket.timeout: + hops.append({"ttl": ttl, "ip": "*", "hostname": "Timeout"}) + except: + break + return hops + +# ============== Main Port Scanner ============== +class PortScanner: + __slots__ = ( + "target", + "timeout", + "threads", + "scan_type", + "timing", + "ports", + "decoys", + "check_vulns", + "rate_limit", + "resolved_ip", + "results", + "vulnerabilities", + "start_time", + "rate_limiter", + ) + + def __init__(self, target: str, **kwargs): + self.target = target + self.timeout = kwargs.get("timeout", 1.0) + self.threads = kwargs.get("threads", 100) + self.scan_type = kwargs.get("scan_type", "tcp") + self.timing = kwargs.get("timing", "T3") + self.ports = kwargs.get("ports", list(COMMON_PORTS.keys())) + self.decoys = kwargs.get("decoys", []) + self.check_vulns = kwargs.get("check_vulns", False) + self.rate_limit = kwargs.get("rate_limit", 0) + self.resolved_ip = None + self.results = [] + self.vulnerabilities = [] + self.start_time = None + self.rate_limiter = RateLimiter(self.rate_limit) if self.rate_limit else None + if self.timing in TIMING_PROFILES: + profile = TIMING_PROFILES[self.timing] + self.threads = profile["threads"] + self.timeout = profile["timeout"] + + def initialize(self): + print(f"\n{BOLD}{CYAN}{'='*60}") + print(f"{BOLD}{CYAN}[*] SpectraScan - Enhanced Edition") + print(f"{CYAN}{'='*60}{RESET}") + self.resolved_ip = resolve_host(self.target) + hostname = reverse_dns(self.resolved_ip) + print(f"{CYAN}[*] Target: {self.target} ({self.resolved_ip})") + print(f"[*] Hostname: {hostname}") + print(f"[*] Scan Type: {self.scan_type.upper()}") + print(f"[*] Ports: {len(self.ports)}") + print(f"[*] Threads: {self.threads}") + print(f"[*] Timing: {self.timing} ({TIMING_PROFILES[self.timing]['name']})") + if self.decoys: + print(f"{YELLOW}[*] Decoys: {len(self.decoys)} IPs active") + if self.rate_limit: + print(f"{YELLOW}[*] Rate Limit: {self.rate_limit} pps") + print(f"{CYAN}{'='*60}{RESET}\n") + + def scan_port(self, port: int) -> Dict: + if self.rate_limiter: + self.rate_limiter.wait() + service_value = COMMON_PORTS.get(port, "unknown") + if self.scan_type == "udp": + udp_result = UDPScan().scan(self.resolved_ip, port, timeout=self.timeout) + service = udp_result.get("service", (service_value, "")) + if isinstance(service, tuple): + service = service[0] if service else "unknown" + return { + "port": port, "protocol": "udp", "state": udp_result.get("state", "error"), + "service": service, "description": PORT_DESCRIPTIONS.get(port, ""), + "banner": "", "response": udp_result.get("response", ""), + "error": udp_result.get("error"), "version_info": {"type": "unknown", "version": "unknown"}, + "vulnerabilities": [], + } + if self.scan_type == "syn": + state = SYNScan().scan(self.resolved_ip, port, timeout=self.timeout) + result = { + "port": port, "protocol": "tcp", "state": state, "service": service_value, + "description": PORT_DESCRIPTIONS.get(port, ""), "banner": "", + "version_info": {"type": "unknown", "version": "unknown"}, "vulnerabilities": [], + } + if state == "open": + result["banner"] = grab_banner(self.resolved_ip, port, self.timeout) + result["version_info"] = identify_service(result["banner"]) + if self.check_vulns: + result["vulnerabilities"] = check_vulnerabilities(port, service_value, result["banner"]) + self.vulnerabilities.extend(result["vulnerabilities"]) + return result + result = { + "port": port, "protocol": "tcp", "state": "closed", + "service": service_value, "description": PORT_DESCRIPTIONS.get(port, ""), + "banner": "", "version_info": {"type": "unknown", "version": "unknown"}, + "vulnerabilities": [], + } + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + if sock.connect_ex((self.resolved_ip, port)) == 0: + result["state"] = "open" + result["banner"] = grab_banner(self.resolved_ip, port, self.timeout) + result["version_info"] = identify_service(result["banner"]) + if self.check_vulns: + result["vulnerabilities"] = check_vulnerabilities(port, result["service"], result["banner"]) + self.vulnerabilities.extend(result["vulnerabilities"]) + except socket.timeout: + result["state"] = "filtered" + except OSError as exc: + result["state"] = "error" + result["error"] = f"{type(exc).__name__}: {exc}" + finally: + if sock is not None: + sock.close() + return result + + def scan(self) -> List[Dict]: + self.start_time = time.time() + self.results = [] + self.vulnerabilities = [] + print(f"{CYAN}[*] Spectra is scanning...\n") + with concurrent.futures.ThreadPoolExecutor(max_workers=self.threads) as executor: + futures = {executor.submit(self.scan_port, port): port for port in self.ports} + for future in concurrent.futures.as_completed(futures): + result = future.result() + if result["state"] == "open" or (self.scan_type == "udp" and result["state"] == "open|filtered"): + self.results.append(result) + self._print_result(result) + self.results.sort(key=lambda item: item["port"]) + return self.results + + def _print_result(self, result: Dict): + vuln_count = len(result.get("vulnerabilities", [])) + vuln_str = f" {RED}[!]{vuln_count} vulns{RESET}" if vuln_count else "" + banner = result.get("banner", "") or "" + banner_display = banner[:50] if len(banner) > 50 else banner + + # Handle service being either a tuple or string + service = result.get("service", "unknown") + if isinstance(service, tuple): + service = service if service else "unknown" + service_display = service or "unknown" + + # Description fallback chain: description field -> PORT_DESCRIPTIONS -> service + description = result.get("description", "") or PORT_DESCRIPTIONS.get( + result.get("port"), service_display + ) + if isinstance(description, tuple): + description_display = description if description else "" + else: + description_display = description + + print( + f"{GREEN}[+] Port {result['port']:>5}/tcp " + f"{result['state']:<10} {service_display:<12} " + f"{CYAN}| {banner_display}{vuln_str}{RESET}" + ) + + def print_summary(self): + duration = time.time() - self.start_time if self.start_time else 0.0 + print(f"\n{CYAN}{'='*60}") + print(f"{GREEN}[✓] Scan completed in {duration:.2f} seconds") + print(f"[+] Found {len(self.results)} open ports") + if self.results: + print(f"\n{YELLOW}Open Ports Summary:{RESET}") + print(f"{'Port':<10}{'Service':<15}{'State':<10}{'Version'}") + print(f"{'-'*45}") + for r in sorted(self.results, key=lambda x: x["port"]): + service = r.get("service", "unknown") + if isinstance(service, tuple): + service = service if service else "unknown" + version = r.get("version_info", {}).get("version", "N/A") + print(f"{r['port']:<10}{service:<15}{r['state']:<10}{version}") + if self.vulnerabilities: + print(f"\n{RED}[!] Found {len(self.vulnerabilities)} vulnerabilities:{RESET}") + for v in self.vulnerabilities: + svc = v.get("service", "unknown") + if isinstance(svc, tuple): + svc = svc if svc else "unknown" + sev_color = RED if v.get("severity") == "HIGH" else YELLOW + print( + f" {RED}•{RESET} Port {v['port']} ({svc}): " + f"{v['vulnerability']} [{sev_color}{v.get('severity', 'INFO')}{RESET}]" + ) + print(f"{CYAN}{'='*60}{RESET}") + + def get_results(self) -> Dict: + return { + "target": self.target, + "resolved_ip": self.resolved_ip, + "scan_type": self.scan_type, + "timestamp": datetime.now().isoformat(), + "duration": (time.time() - self.start_time) if self.start_time else 0.0, + "open_ports": self.results, + "vulnerabilities": self.vulnerabilities, + } + + def export_json(self, filename: str): + path = atomic_write_json(filename, self.get_results()) + print(f"{GREEN}[+] JSON report saved: {path}") + + def export_html(self, filename: str): + results = self.get_results() + target = html.escape(str(results.get("target", ""))) + scan_type = html.escape(str(results.get("scan_type", ""))) + resolved_ip = html.escape(str(results.get("resolved_ip", "") or "")) + timestamp = html.escape(str(results.get("timestamp", ""))) + duration = f"{results.get('duration', 0):.2f}" + parts = [f""" +SpectraScan Report - {target} + +

SpectraScan Report

+

Target: {target}

Resolved IP: {resolved_ip}

+

Scan type: {scan_type}

Timestamp: {timestamp}

+

Duration: {duration}s

+

Open / responsive ports

"""] + for port in results.get("open_ports", []): + service = port.get("service", "unknown") + if isinstance(service, tuple): + service = " ".join(str(part) for part in service if part) + cells = [port.get("port", ""), port.get("protocol", "tcp"), port.get("state", ""), service, (port.get("banner", "") or "")[:300]] + parts.append("" + "".join(f"" for cell in cells) + "") + parts.append("
PortProtocolStateServiceBanner
{html.escape(str(cell))}
") + path = atomic_write_text(filename, "".join(parts)) + print(f"{GREEN}[+] HTML report saved: {path}") + + def export_csv(self, filename: str): + stream = io.StringIO(newline="") + writer = csv.writer(stream) + writer.writerow(["Port", "Protocol", "State", "Service", "Banner", "Vulnerabilities"]) + for port in self.results: + service = port.get("service", "") + if isinstance(service, tuple): + service = " ".join(str(part) for part in service if part) + vulns = "; ".join(str(v.get("vulnerability", "")) for v in port.get("vulnerabilities", [])) or "None" + writer.writerow([ + safe_csv_cell(port.get("port", "")), safe_csv_cell(port.get("protocol", "tcp")), + safe_csv_cell(port.get("state", "")), safe_csv_cell(service), + safe_csv_cell((port.get("banner", "") or "")[:500]), safe_csv_cell(vulns), + ]) + path = atomic_write_text(filename, stream.getvalue()) + print(f"{GREEN}[+] CSV report saved: {path}") + +# ============== Network Scanner ============== +class NetworkScanner: + @staticmethod + def scan_network(network: str, ports: List[int] = None, **kwargs) -> List[Dict]: + max_targets = int(kwargs.pop("max_targets", 1024)) + exclusions = kwargs.pop("exclusions", None) + host_workers = int(kwargs.pop("host_workers", 4)) + confirmed = bool(kwargs.pop("large_scan_confirmed", False)) + rows = run_network_port_scan( + network, {"ports": ports or list(COMMON_PORTS.keys()), **kwargs}, + exclusions=exclusions, max_targets=max_targets, + host_workers=host_workers, large_scan_confirmed=confirmed, + ) + return [ + {"ip": row["ip"], "hostname": reverse_dns(row["ip"]), "ports": row["ports"]} + for row in rows + ] + + +# ============== Protocol Module Scanner ============== +class ProtocolModuleScanner: + """Stub implementation of protocol scanners. + Replace these methods with calls into the real modules in ./modules if present. + """ + + @staticmethod + def _print_result(name: str, result: Dict): + console.print(f"\n[bold cyan]--- {name} ---[/bold cyan]") + if isinstance(result, dict): + for key, value in result.items(): + console.print(f" [green]{key}:[/green] {value}") + else: + console.print(f" {result}") + + @staticmethod + def _tcp_probe(ip: str, port: int, timeout: float = 5.0) -> Dict: + result = {"ip": ip, "port": port, "state": "closed", "banner": None} + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + if sock.connect_ex((ip, port)) == 0: + result["state"] = "open" + try: + sock.settimeout(2.0) + data = sock.recv(256) + if data: + result["banner"] = data.decode("utf-8", errors="ignore").strip() + except: + pass + sock.close() + except socket.timeout: + result["state"] = "filtered" + except Exception as e: + result["state"] = "error" + result["error"] = str(e) + return result + + @staticmethod + def run_smb(target: str): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + result = ProtocolModuleScanner._tcp_probe(ip, 445) + ProtocolModuleScanner._print_result("SMB Enumerator", result) + return result + + @staticmethod + def run_snmp(target: str): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + result = ProtocolModuleScanner._tcp_probe(ip, 161, timeout=3.0) + # SNMP is UDP, so do a quick UDP probe too + try: + usock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + usock.settimeout(3.0) + probe = b"\x30\x00\x00\x00\x02\x01\x00\x04\x06public\xa0\x1f\x02\x01\x00\x02\x01\x00\x30\x14" + usock.sendto(probe, (ip, 161)) + data, _ = usock.recvfrom(1024) + result["snmp_response"] = data.hex()[:100] + result["state"] = "open" + usock.close() + except socket.timeout: + result["snmp_response"] = None + except Exception as e: + result["snmp_error"] = str(e) + ProtocolModuleScanner._print_result("SNMP Enumerator", result) + return result + + @staticmethod + def run_ldap(target: str, port: int = 389): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + result = ProtocolModuleScanner._tcp_probe(ip, port) + ProtocolModuleScanner._print_result("LDAP Enumerator", result) + return result + + @staticmethod + def run_rdp(target: str): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + result = ProtocolModuleScanner._tcp_probe(ip, 3389) + ProtocolModuleScanner._print_result("RDP Enumerator", result) + return result + + @staticmethod + def run_smtp(target: str, port: int = 25, test_relay: bool = True): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + result = {"banner": None, "open_relay": None, "error": None} + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((ip, port)) + banner = sock.recv(1024).decode("utf-8", errors="ignore").strip() + result["banner"] = banner + if test_relay: + sock.send(b"EHLO test.local\r\n") + sock.recv(1024) + sock.send(b"MAIL FROM:\r\n") + resp = sock.recv(1024).decode("utf-8", errors="ignore") + sock.send(b"RCPT TO:\r\n") + resp2 = sock.recv(1024).decode("utf-8", errors="ignore") + result["open_relay"] = "250" in resp2 + sock.send(b"QUIT\r\n") + sock.close() + except Exception as e: + result["error"] = str(e) + ProtocolModuleScanner._print_result("SMTP Enumerator", result) + return result + + @staticmethod + def run_dns_zone(domain: str): + result = {"domain": domain, "records": {}} + try: + import dns.resolver + import dns.zone + import dns.query + ns_answers = dns.resolver.resolve(domain, "NS") + for ns in ns_answers: + ns_str = str(ns).rstrip(".") + try: + z = dns.zone.from_xfr(dns.query.xfr(ns_str, domain, timeout=5)) + result["records"][ns_str] = [str(n) for n in z.nodes.keys()] + except Exception as e: + result["records"][ns_str] = f"Transfer failed: {e}" + except ImportError: + result["records"]["error"] = "dnspython not installed" + except Exception as e: + result["records"]["error"] = str(e) + ProtocolModuleScanner._print_result("DNS Zone Transfer", result) + return result + + @staticmethod + def run_nfs(target: str): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + result = ProtocolModuleScanner._tcp_probe(ip, 2049) + ProtocolModuleScanner._print_result("NFS Enumerator", result) + return result + + @staticmethod + def run_vnc(target: str, port: int = 5900): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + result = ProtocolModuleScanner._tcp_probe(ip, port) + ProtocolModuleScanner._print_result("VNC Enumerator", result) + return result + + @staticmethod + def run_redis(target: str, port: int = 6379): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + result = {"ip": ip, "port": port} + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((ip, port)) + sock.send(b"PING\r\n") + data = sock.recv(1024).decode("utf-8", errors="ignore").strip() + result["response"] = data + result["unauth"] = "PONG" in data + sock.close() + except Exception as e: + result["error"] = str(e) + ProtocolModuleScanner._print_result("Redis Enumerator", result) + return result + + @staticmethod + def run_mongodb(target: str, port: int = 27017): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + result = {"ip": ip, "port": port} + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((ip, port)) + # Send a minimal ismaster command + sock.close() + result["state"] = "open" + except Exception as e: + result["error"] = str(e) + ProtocolModuleScanner._print_result("MongoDB Enumerator", result) + return result + + @staticmethod + def run_sip(target: str, port: int = 5060): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + result = {"ip": ip, "port": port, "state": "unknown"} + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(3.0) + sock.sendto(b"OPTIONS sip:test@" + ip.encode() + b" SIP/2.0\r\n\r\n", (ip, port)) + data, _ = sock.recvfrom(1024) + result["state"] = "open" + result["response"] = data.decode("utf-8", errors="ignore").strip()[:200] + sock.close() + except socket.timeout: + result["state"] = "open|filtered" + except Exception as e: + result["error"] = str(e) + ProtocolModuleScanner._print_result("SIP Enumerator", result) + return result + + @staticmethod + def run_rtsp(target: str, port: int = 554): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + result = {"ip": ip, "port": port} + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + sock.connect((ip, port)) + sock.send(b"OPTIONS * RTSP/1.0\r\nCSeq: 1\r\n\r\n") + data = sock.recv(1024).decode("utf-8", errors="ignore").strip() + result["response"] = data[:200] + result["state"] = "open" + sock.close() + except Exception as e: + result["error"] = str(e) + ProtocolModuleScanner._print_result("RTSP Enumerator", result) + return result + + @staticmethod + def run_databases(target: str): + ip = target if target.replace(".", "").isdigit() else resolve_host(target) + db_ports = { + "mysql": 3306, + "postgres": 5432, + "mssql": 1433, + "oracle": 1521, + } + results = {} + for db, port in db_ports.items(): + results[db] = ProtocolModuleScanner._tcp_probe(ip, port, timeout=3.0) + for db_name, db_result in results.items(): + ProtocolModuleScanner._print_result(db_name.upper(), db_result) + return results + + +def run_protocol_modules(): + """Interactive menu for protocol modules.""" + console.print("\n[bold cyan]--- PROTOCOL ENUMERATION MODULES ---[/bold cyan]") + console.print("[bold green]1.[/bold green] SMB Enumerator") + console.print("[bold green]2.[/bold green] SNMP Enumerator") + console.print("[bold green]3.[/bold green] LDAP Enumerator") + console.print("[bold green]4.[/bold green] RDP Enumerator") + console.print("[bold green]5.[/bold green] SMTP Enumerator") + console.print("[bold green]6.[/bold green] DNS Zone Transfer") + console.print("[bold green]7.[/bold green] NFS Enumerator") + console.print("[bold green]8.[/bold green] VNC Enumerator") + console.print("[bold green]9.[/bold green] Redis Enumerator") + console.print("[bold green]10.[/bold green] MongoDB Enumerator") + console.print("[bold green]11.[/bold green] SIP Enumerator") + console.print("[bold green]12.[/bold green] RTSP Enumerator") + console.print("[bold green]13.[/bold green] Database Scan (MySQL/Postgres/MSSQL)") + console.print("[bold blue]14.[/bold blue] Dark Web Recon") + console.print("[bold red]15.[/bold red] Back to main menu") + + choice = hacker_input("Select module") + + def _safe_int(prompt: str, default: int) -> int: + raw = hacker_input(prompt, str(default)) + try: + return int(raw) + except (TypeError, ValueError): + return default + + try: + if choice == "1": + target = hacker_input("Enter target IP/hostname") + if target: + ProtocolModuleScanner.run_smb(target) + elif choice == "2": + target = hacker_input("Enter target IP/hostname") + if target: + ProtocolModuleScanner.run_snmp(target) + elif choice == "3": + target = hacker_input("Enter target IP/hostname") + port = _safe_int("Port (default 389)", 389) + if target: + ProtocolModuleScanner.run_ldap(target, port) + elif choice == "4": + target = hacker_input("Enter target IP/hostname") + if target: + ProtocolModuleScanner.run_rdp(target) + elif choice == "5": + target = hacker_input("Enter target IP/hostname") + relay = Confirm.ask("Test open relay?", default=True) + if target: + ProtocolModuleScanner.run_smtp(target, test_relay=relay) + elif choice == "6": + domain = hacker_input("Enter domain (e.g., example.com)") + if domain: + ProtocolModuleScanner.run_dns_zone(domain) + elif choice == "7": + target = hacker_input("Enter target IP/hostname") + if target: + ProtocolModuleScanner.run_nfs(target) + elif choice == "8": + target = hacker_input("Enter target IP/hostname") + port = _safe_int("Port (default 5900)", 5900) + if target: + ProtocolModuleScanner.run_vnc(target, port) + elif choice == "9": + target = hacker_input("Enter target IP/hostname") + if target: + ProtocolModuleScanner.run_redis(target) + elif choice == "10": + target = hacker_input("Enter target IP/hostname") + if target: + ProtocolModuleScanner.run_mongodb(target) + elif choice == "11": + target = hacker_input("Enter target IP/hostname") + if target: + ProtocolModuleScanner.run_sip(target) + elif choice == "12": + target = hacker_input("Enter target IP/hostname") + if target: + ProtocolModuleScanner.run_rtsp(target) + elif choice == "13": + target = hacker_input("Enter target IP/hostname") + if target: + ProtocolModuleScanner.run_databases(target) + elif choice == "14": + # ---- Dark Web Recon ---- + try: + run_darkweb_menu() + except KeyboardInterrupt: + console.print( + "[*] Dark Web module interrupted.", + style="yellow") + except Exception as e: + console.print( + f"[!] Dark Web module error: {e}", + style="bold red") + elif choice == "15": + return + else: + console.print("[!] Invalid option.", style="red") + except Exception as e: + console.print(f"{RED}[!] Error: {e}{RESET}", style="red") + + +# ============== Domain Intelligence Scanner ============== +class DomainScanner: + """Passive-first domain intelligence for SpectraScan. + + Keeps the existing DomainScanner.scan(domain, report_manager) API while + adding WHOIS, DNS, SPF, DMARC, DKIM selector checks, CAA, DNSSEC evidence, + Certificate Transparency, passive subdomain discovery, HTTP/TLS metadata, + security headers, technology/CDN hints, robots.txt, sitemap and scoring. + """ + + VERSION = "2.0.0" + HTTP_TIMEOUT = 12 + DNS_TIMEOUT = 4 + CT_TIMEOUT = 20 + USER_AGENT = f"SpectraScan-DomainIntelligence/{VERSION}" + + DNS_TYPES = ("A", "AAAA", "MX", "NS", "TXT", "CNAME", "SOA", "CAA", "SRV") + SECURITY_HEADERS = ( + "strict-transport-security", "content-security-policy", + "x-content-type-options", "x-frame-options", "referrer-policy", + "permissions-policy", "cross-origin-opener-policy", + "cross-origin-resource-policy", + ) + DKIM_SELECTORS = ( + "default", "selector1", "selector2", "google", "k1", "k2", + "mail", "dkim", "s1", "s2", "smtp", "mandrill", "zoho", + "sendgrid", "mailjet", "amazonses", "protonmail", + ) + TECH_PATTERNS = { + "WordPress": (r"/wp-content/", r"/wp-includes/", r"wp-json"), + "Drupal": (r"drupalSettings", r"/sites/default/"), + "Joomla": (r"/media/system/", r"joomla"), + "Next.js": (r"__NEXT_DATA__", r"/_next/"), + "Nuxt": (r"__NUXT__", r"/_nuxt/"), + "React": (r"react(?:dom)?",), + "Vue.js": (r"vue(?:\.router)?",), + "Angular": (r"ng-version", r"angular"), + "jQuery": (r"jquery",), + "Bootstrap": (r"bootstrap",), + "Tailwind CSS": (r"tailwind",), + "Google Analytics": (r"google-analytics", r"gtag", r"googletagmanager"), + "Google Tag Manager": (r"googletagmanager", r"gtm\.js"), + "Cloudflare": (r"cf-ray", r"cloudflare"), + } + CDN_PATTERNS = { + "Cloudflare": ("cf-ray", "cf-cache-status", "cloudflare"), + "Akamai": ("akamai", "x-akamai"), + "Fastly": ("fastly", "x-served-by"), + "Amazon CloudFront": ("cloudfront", "x-amz-cf-id", "x-amz-cf-pop"), + "Imperva": ("imperva", "incap_ses", "visid_incap"), + "Azure Front Door": ("x-azure-ref", "azure"), + } + + @staticmethod + def normalize_domain(value: str) -> str: + value = (value or "").strip() + if not value: + return "" + if "://" not in value: + value = "https://" + value + try: + host = __import__("urllib.parse", fromlist=["urlparse"]).urlparse(value).hostname or "" + except Exception: + host = "" + return host.lower().rstrip(".") + + @staticmethod + def _unique(values): + seen, out = set(), [] + for value in values or []: + if value is None: + continue + value = str(value).strip().rstrip(".") + if value and value.lower() not in seen: + seen.add(value.lower()) + out.append(value) + return out + + @staticmethod + def _write(report_manager, text): + try: + report_manager.write(str(text)) + except Exception: + pass + + @classmethod + def _http(cls, url, timeout=None): + try: + import requests + return requests.get( + url, + headers={"User-Agent": cls.USER_AGENT, "Accept": "*/*"}, + timeout=timeout or cls.HTTP_TIMEOUT, + allow_redirects=True, + verify=True, + ) + except Exception: + return None + + @classmethod + def _whois(cls, domain): + result = {"available": False, "source": None, "registrar": None, + "organization": None, "creation_date": None, + "expiration_date": None, "updated_date": None, + "name_servers": [], "status": [], "emails": [], "raw": None} + try: + import whois + data = whois.whois(domain) + result["available"] = True + result["source"] = "python-whois" + result["registrar"] = str(data.registrar) if data.registrar else None + result["organization"] = str(getattr(data, "org", None)) if getattr(data, "org", None) else None + for key, attr in (("creation_date", "creation_date"), ("expiration_date", "expiration_date"), ("updated_date", "updated_date")): + value = getattr(data, attr, None) + if value: + result[key] = str(value) + result["name_servers"] = cls._unique(getattr(data, "name_servers", None) or []) + result["status"] = cls._unique(getattr(data, "status", None) or []) + result["emails"] = cls._unique(getattr(data, "emails", None) or []) + return result + except Exception as exc: + result["error"] = str(exc) + try: + proc = subprocess.run(["whois", domain], capture_output=True, text=True, timeout=20) + raw = (proc.stdout or proc.stderr or "").strip() + if raw: + result["available"] = True + result["source"] = "system-whois" + result["raw"] = raw + patterns = { + "registrar": r"(?im)^Registrar(?: Name)?:\s*(.+)$", + "organization": r"(?im)^(?:Registrant Organization|Organization):\s*(.+)$", + "creation_date": r"(?im)^(?:Creation Date|Created|Registered On):\s*(.+)$", + "expiration_date": r"(?im)^(?:Registry Expiry Date|Expiration Date|Expiry Date):\s*(.+)$", + "updated_date": r"(?im)^(?:Updated Date|Last Updated):\s*(.+)$", + } + for key, pattern in patterns.items(): + match = re.search(pattern, raw) + if match: + result[key] = match.group(1).strip() + result["name_servers"] = cls._unique(re.findall(r"(?im)^(?:Name Server|Nameserver|nserver):\s*([^\s]+)", raw)) + except Exception as exc: + result["error"] = str(exc) + return result + + @classmethod + def _dns(cls, domain): + result = {"records": {}, "errors": {}, "resolved_ips": []} + try: + import dns.resolver + resolver = dns.resolver.Resolver() + resolver.timeout = cls.DNS_TIMEOUT + resolver.lifetime = cls.DNS_TIMEOUT + 1 + for rtype in cls.DNS_TYPES: + values = [] + try: + answers = resolver.resolve(domain, rtype, raise_on_no_answer=False) + for answer in answers: + if rtype == "MX": + values.append(f"{answer.exchange} (pref: {answer.preference})") + elif rtype == "SOA": + values.append(str(answer)) + else: + values.append(str(answer).strip('"').rstrip('.')) + except Exception as exc: + result["errors"][rtype] = str(exc) + result["records"][rtype] = cls._unique(values) + except ImportError: + result["errors"]["dns"] = "dnspython is not installed" + except Exception as exc: + result["errors"]["dns"] = str(exc) + try: + result["resolved_ips"] = cls._unique( + result["records"].get("A", []) + result["records"].get("AAAA", []) + ) + if not result["resolved_ips"]: + result["resolved_ips"] = cls._unique( + item[4][0] for item in socket.getaddrinfo(domain, None) + ) + except Exception: + pass + return result + + @classmethod + def _spf(cls, dns_data): + records = [x for x in dns_data.get("records", {}).get("TXT", []) if x.lower().startswith("v=spf1")] + return {"present": bool(records), "records": records, "mechanisms": cls._unique(" ".join(records).split()[1:])} + + @classmethod + def _dmarc(cls, domain): + result = {"present": False, "record": None, "policy": None} + try: + import dns.resolver + resolver = dns.resolver.Resolver() + resolver.timeout = cls.DNS_TIMEOUT + resolver.lifetime = cls.DNS_TIMEOUT + 1 + values = [str(x).strip('"') for x in resolver.resolve(f"_dmarc.{domain}", "TXT")] + record = next((x for x in values if x.lower().startswith("v=dmarc1")), None) + if record: + result["present"] = True + result["record"] = record + match = re.search(r"(?:^|;)\s*p=([^;]+)", record, re.I) + result["policy"] = match.group(1).strip().lower() if match else None + except Exception as exc: + result["error"] = str(exc) + return result + + @classmethod + def _dkim(cls, domain): + result = {"tested": list(cls.DKIM_SELECTORS), "found": []} + try: + import dns.resolver + resolver = dns.resolver.Resolver() + resolver.timeout = 2 + resolver.lifetime = 3 + for selector in cls.DKIM_SELECTORS: + try: + values = [str(x).strip('"') for x in resolver.resolve(f"{selector}._domainkey.{domain}", "TXT")] + if values: + result["found"].append({"selector": selector, "records": values}) + except Exception: + continue + except ImportError: + result["error"] = "dnspython is not installed" + return result + + @staticmethod + def _security_headers(headers): + normalized = {str(k).lower(): str(v) for k, v in headers.items()} + return {key: normalized.get(key) for key in DomainScanner.SECURITY_HEADERS} + + @classmethod + def _technologies(cls, response): + body = (response.text or "")[:1500000] + blob = body + "\n" + "\n".join(f"{k}: {v}" for k, v in response.headers.items()) + found = set() + for name, patterns in cls.TECH_PATTERNS.items(): + if any(re.search(p, blob, re.I) for p in patterns): + found.add(name) + if response.headers.get("Server"): + found.add("Server: " + response.headers["Server"].strip()) + if response.headers.get("X-Powered-By"): + found.add("Powered-By: " + response.headers["X-Powered-By"].strip()) + return sorted(found) + + @classmethod + def _cdn(cls, headers): + blob = "\n".join(f"{k}: {v}" for k, v in headers.items()).lower() + return sorted(name for name, patterns in cls.CDN_PATTERNS.items() if any(p.lower() in blob for p in patterns)) + + @staticmethod + def _title(body): + match = re.search(r"]*>(.*?)", body or "", re.I | re.S) + return re.sub(r"\s+", " ", html.unescape(match.group(1))).strip()[:300] if match else None + + @classmethod + def _web(cls, domain): + result = {"preferred_url": None, "http": {}, "https": {}, "redirect_chain": [], + "headers": {}, "security_headers": {}, "server": None, + "content_type": None, "title": None, "technologies": [], "cdn": []} + responses = {} + for scheme in ("https", "http"): + response = cls._http(f"{scheme}://{domain}/") + if response is not None: + responses[scheme] = response + result[scheme] = { + "available": True, "status": response.status_code, + "final_url": response.url, + "history": [{"status": x.status_code, "url": x.url, "location": x.headers.get("Location")} for x in response.history], + "headers": dict(response.headers), + } + else: + result[scheme] = {"available": False} + response = responses.get("https") or responses.get("http") + if response is not None: + result["preferred_url"] = response.url + result["redirect_chain"] = [x.url for x in response.history] + [response.url] + result["headers"] = dict(response.headers) + result["security_headers"] = cls._security_headers(response.headers) + result["server"] = response.headers.get("Server") + result["content_type"] = response.headers.get("Content-Type") + result["title"] = cls._title(response.text) + result["technologies"] = cls._technologies(response) + result["cdn"] = cls._cdn(response.headers) + return result + + @classmethod + def _tls(cls, domain): + result = {"available": False, "version": None, "cipher": None, "subject": {}, "issuer": {}, "san": [], "not_before": None, "not_after": None} + try: + context = ssl.create_default_context() + with socket.create_connection((domain, 443), timeout=8) as sock: + with context.wrap_socket(sock, server_hostname=domain) as tls: + cert = tls.getpeercert() + result["available"] = True + result["version"] = tls.version() + result["cipher"] = tls.cipher() + result["subject"] = dict(x[0] for x in cert.get("subject", ())) + result["issuer"] = dict(x[0] for x in cert.get("issuer", ())) + result["not_before"] = cert.get("notBefore") + result["not_after"] = cert.get("notAfter") + result["san"] = cls._unique(v for k, v in cert.get("subjectAltName", ()) if k.lower() == "dns") + except Exception as exc: + result["error"] = str(exc) + return result + + @classmethod + def _ct(cls, domain): + result = {"available": False, "source": "crt.sh", "certificates": 0, "names": []} + try: + import requests + response = requests.get( + "https://crt.sh/", params={"q": f"%.{domain}", "output": "json"}, + headers={"User-Agent": cls.USER_AGENT}, timeout=cls.CT_TIMEOUT, verify=True, + ) + if response.status_code != 200: + result["error"] = f"HTTP {response.status_code}" + return result + records = response.json() + names = set() + for record in records: + for name in str(record.get("name_value", "")).splitlines(): + name = name.strip().lower().replace("*.", "") + if name == domain or name.endswith("." + domain): + names.add(name) + result.update({"available": True, "certificates": len(records), "names": sorted(names)}) + except Exception as exc: + result["error"] = str(exc) + return result + + @classmethod + def _subdomains(cls, domain, ct): + candidates = set(x for x in ct.get("names", []) if x != domain and x.endswith("." + domain)) + for prefix in COMMON_SUBDOMAINS: + candidates.add(f"{prefix}.{domain}") + resolved = [] + for host in sorted(candidates): + try: + ips = cls._unique(item[4][0] for item in socket.getaddrinfo(host, None)) + if ips: + resolved.append({"hostname": host, "ips": ips}) + except Exception: + pass + return {"candidates": sorted(candidates), "resolved": resolved, "count": len(resolved)} + + @classmethod + def _robots(cls, domain): + result = {"found": False, "status": None, "url": None, "sitemaps": [], "disallow": [], "allow": []} + for scheme in ("https", "http"): + response = cls._http(f"{scheme}://{domain}/robots.txt") + if response is not None and response.status_code == 200: + result.update({"found": True, "status": response.status_code, "url": response.url}) + for line in response.text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or ":" not in line: + continue + key, value = line.split(":", 1) + value = value.strip() + if key.lower() == "sitemap": result["sitemaps"].append(value) + elif key.lower() == "disallow" and value: result["disallow"].append(value) + elif key.lower() == "allow" and value: result["allow"].append(value) + result["sitemaps"] = cls._unique(result["sitemaps"]) + result["disallow"] = cls._unique(result["disallow"]) + result["allow"] = cls._unique(result["allow"]) + return result + return result + + @classmethod + def _sitemap(cls, domain, robots): + candidates = cls._unique(robots.get("sitemaps", []) + [f"https://{domain}/sitemap.xml", f"https://{domain}/sitemap_index.xml"]) + for url in candidates: + response = cls._http(url) + if response is None or response.status_code != 200: + continue + body = response.text + if "\s*(.*?)\s*", body, re.I | re.S)) + return {"found": True, "url": response.url, "status": response.status_code, "urls": urls[:5000]} + return {"found": False, "url": None, "status": None, "urls": []} + + @classmethod + def _reverse_dns(cls, ips): + result = {} + for ip in ips: + try: + result[ip] = socket.gethostbyaddr(ip)[0] + except Exception: + result[ip] = None + return result + + @classmethod + def _infrastructure(cls, dns_data): + ips = dns_data.get("resolved_ips", []) + result = {"ips": ips, "reverse_dns": cls._reverse_dns(ips)} + api_key = os.environ.get("SHODAN_API_KEY") + if api_key and ips: + try: + import requests + response = requests.get( + f"https://api.shodan.io/shodan/host/{ips[0]}", + params={"key": api_key}, headers={"User-Agent": cls.USER_AGENT}, timeout=10, + ) + if response.status_code == 200: + data = response.json() + result["shodan"] = { + "ip": data.get("ip_str"), "organization": data.get("org"), + "isp": data.get("isp"), "asn": data.get("asn"), + "os": data.get("os"), "ports": data.get("ports", []), + } + except Exception as exc: + result["shodan_error"] = str(exc) + return result + + @classmethod + def _score(cls, dns_data, spf, dmarc, dkim, web, tls): + score = 0 + if dns_data.get("records", {}).get("A") or dns_data.get("records", {}).get("AAAA"): score += 10 + if dns_data.get("records", {}).get("NS"): score += 5 + if dns_data.get("records", {}).get("SOA"): score += 5 + if dns_data.get("records", {}).get("CAA"): score += 5 + if spf.get("present"): score += 8 + if dmarc.get("present"): score += 8 + if dmarc.get("policy") in ("quarantine", "reject"): score += 4 + if dkim.get("found"): score += 5 + if tls.get("available"): score += 15 + if tls.get("version") in ("TLSv1.2", "TLSv1.3"): score += 5 + if web.get("https", {}).get("available"): score += 8 + headers = web.get("security_headers", {}) + score += min(17, sum(2 for value in headers.values() if value)) + score = min(100, score) + rating = "EXCELLENT" if score >= 90 else "STRONG" if score >= 75 else "MODERATE" if score >= 60 else "WEAK" if score >= 40 else "HIGH EXPOSURE" + return {"score": score, "rating": rating} + + @classmethod + def _findings(cls, spf, dmarc, caa, dnssec, web, tls, robots, ct): + findings = [] + if not spf.get("present"): findings.append({"severity": "MEDIUM", "category": "Email", "message": "No SPF record observed."}) + if not dmarc.get("present"): findings.append({"severity": "MEDIUM", "category": "Email", "message": "No DMARC record observed."}) + elif dmarc.get("policy") == "none": findings.append({"severity": "LOW", "category": "Email", "message": "DMARC policy is set to none."}) + if not caa.get("records"): findings.append({"severity": "LOW", "category": "DNS", "message": "No CAA record observed."}) + if not dnssec.get("present"): findings.append({"severity": "LOW", "category": "DNS", "message": "DNSSEC evidence was not observed."}) + missing = [k for k, v in web.get("security_headers", {}).items() if not v] + if missing: findings.append({"severity": "LOW", "category": "Web", "message": f"{len(missing)} recommended security headers were not observed.", "details": missing}) + if web.get("https", {}).get("available") and not web.get("security_headers", {}).get("strict-transport-security"): + findings.append({"severity": "LOW", "category": "TLS", "message": "HTTPS is available but HSTS was not observed."}) + if not tls.get("available"): findings.append({"severity": "MEDIUM", "category": "TLS", "message": "TLS certificate inspection failed."}) + if robots.get("disallow"): findings.append({"severity": "INFO", "category": "Web", "message": "robots.txt exposes disallowed paths.", "details": robots["disallow"][:50]}) + if ct.get("names"): findings.append({"severity": "INFO", "category": "CT", "message": f"{len(ct['names'])} certificate names discovered."}) + return findings + + @classmethod + def scan(cls, domain: str, report_manager: ReportManager): + started = time.time() + domain = cls.normalize_domain(domain) + if not domain or len(domain) > 253 or " " in domain: + cls._write(report_manager, "[-] Invalid domain.") + return None + + cls._write(report_manager, f"\n-----DOMAIN INTELLIGENCE SCAN: {domain}-----\n") + data = {"module": "Domain Intelligence", "version": cls.VERSION, "domain": domain, + "timestamp": datetime.now().isoformat(), "whois": {}, "dns": {}, "spf": {}, + "dmarc": {}, "dkim": {}, "caa": {}, "dnssec": {}, "certificate_transparency": {}, + "subdomains": {}, "infrastructure": {}, "web": {}, "tls": {}, "robots": {}, + "sitemap": {}, "findings": [], "score": {}} + + steps = [ + ("WHOIS", lambda: cls._whois(domain), "whois"), + ("DNS", lambda: cls._dns(domain), "dns"), + ] + for label, func, key in steps: + cls._write(report_manager, f"[*] Collecting {label}...") + try: data[key] = func() + except Exception as exc: data[key] = {"error": str(exc)} + + try: data["spf"] = cls._spf(data["dns"]) + except Exception as exc: data["spf"] = {"error": str(exc), "present": False} + try: data["dmarc"] = cls._dmarc(domain) + except Exception as exc: data["dmarc"] = {"error": str(exc), "present": False} + try: data["dkim"] = cls._dkim(domain) + except Exception as exc: data["dkim"] = {"error": str(exc), "found": []} + + records = data["dns"].get("records", {}) + data["caa"] = {"present": bool(records.get("CAA")), "records": records.get("CAA", [])} + data["dnssec"] = {"present": bool(records.get("DS") or records.get("DNSKEY")), "records": {"DS": records.get("DS", []), "DNSKEY": records.get("DNSKEY", [])}} + + cls._write(report_manager, "[*] Querying Certificate Transparency...") + data["certificate_transparency"] = cls._ct(domain) + data["subdomains"] = cls._subdomains(domain, data["certificate_transparency"]) + cls._write(report_manager, "[*] Inspecting HTTP/HTTPS and TLS...") + data["web"] = cls._web(domain) + data["tls"] = cls._tls(domain) + data["robots"] = cls._robots(domain) + data["sitemap"] = cls._sitemap(domain, data["robots"]) + data["infrastructure"] = cls._infrastructure(data["dns"]) + data["related_hosts"] = cls._unique( + records.get("NS", []) + records.get("CNAME", []) + records.get("MX", []) + ) + data["findings"] = cls._findings(data["spf"], data["dmarc"], data["caa"], data["dnssec"], data["web"], data["tls"], data["robots"], data["certificate_transparency"]) + data["score"] = cls._score(data["dns"], data["spf"], data["dmarc"], data["dkim"], data["web"], data["tls"]) + data["runtime_seconds"] = round(time.time() - started, 2) + + cls._write(report_manager, "\n[ WHOIS ]") + whois_data = data["whois"] + for key in ("registrar", "organization", "creation_date", "updated_date", "expiration_date"): + cls._write(report_manager, f"{key.replace('_', ' ').title()}: {whois_data.get(key) or 'Unknown'}") + cls._write(report_manager, "Name Servers: " + (", ".join(whois_data.get("name_servers", [])) or "Unknown")) + + cls._write(report_manager, "\n[ DNS ]") + for rtype in cls.DNS_TYPES: + values = records.get(rtype, []) + if values: cls._write(report_manager, f"{rtype}: {', '.join(values[:25])}") + + cls._write(report_manager, "\n[ EMAIL SECURITY ]") + cls._write(report_manager, f"SPF: {'FOUND' if data['spf'].get('present') else 'NOT FOUND'}") + cls._write(report_manager, f"DMARC: {data['dmarc'].get('policy') or 'NOT FOUND'}") + cls._write(report_manager, f"DKIM selectors found: {len(data['dkim'].get('found', []))}") + + cls._write(report_manager, "\n[ WEB / TLS ]") + cls._write(report_manager, f"URL: {data['web'].get('preferred_url') or 'Unavailable'}") + cls._write(report_manager, f"Title: {data['web'].get('title') or 'Unknown'}") + cls._write(report_manager, f"Server: {data['web'].get('server') or 'Unknown'}") + cls._write(report_manager, "Technologies: " + (", ".join(data['web'].get('technologies', [])) or "None detected")) + cls._write(report_manager, "CDN/WAF: " + (", ".join(data['web'].get('cdn', [])) or "None detected")) + cls._write(report_manager, f"TLS: {data['tls'].get('version') or 'Unavailable'}") + cls._write(report_manager, f"TLS SANs: {len(data['tls'].get('san', []))}") + + cls._write(report_manager, "\n[ CERTIFICATE TRANSPARENCY / SUBDOMAINS ]") + cls._write(report_manager, f"Certificates: {data['certificate_transparency'].get('certificates', 0)}") + cls._write(report_manager, f"Resolved subdomains: {data['subdomains'].get('count', 0)}") + for item in data["subdomains"].get("resolved", [])[:100]: + cls._write(report_manager, f" {item['hostname']} -> {', '.join(item['ips'])}") + + cls._write(report_manager, "\n[ PUBLIC RESOURCES ]") + cls._write(report_manager, f"robots.txt: {'FOUND' if data['robots'].get('found') else 'NOT FOUND'}") + cls._write(report_manager, f"sitemap: {'FOUND' if data['sitemap'].get('found') else 'NOT FOUND'}") + + cls._write(report_manager, "\n[ FINDINGS ]") + for finding in data["findings"] or [{"severity": "INFO", "category": "General", "message": "No notable findings."}]: + cls._write(report_manager, f"[{finding['severity']}] {finding['category']}: {finding['message']}") + + cls._write(report_manager, f"\n[ INTELLIGENCE SCORE ] {data['score']['score']}/100 — {data['score']['rating']}") + cls._write(report_manager, f"[*] DOMAIN INTELLIGENCE COMPLETE in {data['runtime_seconds']:.2f}s") + return data + +# ============== IP Scanner ============== +class IPScanner: + """Integrates SpectraScan IP Scanner features""" + @staticmethod + def scan(ip: str, report_manager: ReportManager): + report_manager.write(f"\n-----IP SCAN OF {ip}-----\n") + + # GeoIP + report_manager.write("[*] LOCALISATION") + try: + result = subprocess.run( + ["curl", "-s", f"https://api.hackertarget.com/geoip/?q={ip}"], + capture_output=True, + text=True, + timeout=15, + ) + report_manager.write(result.stdout) + except FileNotFoundError: + report_manager.write("[-] 'curl' not found.") + except Exception as e: + report_manager.write(f"Error with GeoIP: {e}") + + # WHOIS + report_manager.write("\n[*] ADMIN INFO (WHOIS)") + report_manager.write(DomainScanner._run_whois(ip)) + + # Shodan + report_manager.write("\n[*] SHODAN RESULTS") + api_key = os.environ.get("SHODAN_API_KEY") + if not api_key: + report_manager.write("[-] SHODAN_API_KEY environment variable not set. Skipping Shodan lookup.") + else: + try: + import shodan + api = shodan.Shodan(api_key) + result = api.host(ip) + ports = result.get("ports", []) + report_manager.write(f"Open Ports: {len(ports)}") + for port in ports: + banner = "" + for entry in result.get("data", []): + if entry.get("port") == port: + banner = entry.get("product", "Unknown") + break + report_manager.write(f" Port {port}: {banner}") + except ImportError: + report_manager.write("[-] Shodan Python library not found. Install with: pip install shodan") + except Exception as e: + report_manager.write(f"Error with Shodan: {e}") + + report_manager.write("\n[*] DONE") + +# ============== Phone Scanner ============== +class PhoneScanner: + """Integrates SpectraScan Phone Scanner features""" + @staticmethod + def scan(phone: str, report_manager: ReportManager): + report_manager.write(f"\nScan of {phone}\n") + report_manager.write("Gathering Information...") + report_manager.write(f"\n[*] PHONE {phone}") + report_manager.write("-------------------------------------------------------------------------------") + + # Uses the external python script from the original codebase + script_path = os.path.join(os.path.dirname(__file__), "modules", "phone_scanner.py") + if os.path.exists(script_path): + try: + result = subprocess.run( + ["python3", script_path, phone], + capture_output=True, + text=True, + timeout=60, + ) + report_manager.write(result.stdout) + if result.stderr: + report_manager.write(f"Stderr: {result.stderr}") + except Exception as e: + report_manager.write(f"Error running phone scanner: {e}") + else: + report_manager.write("Phone scanner module not found (modules/phone_scanner.py).") + + report_manager.write("\n[*] DONE") + +# ============== Email Scanner ============== +class EmailScanner: + """Integrates SpectraScan Email Scanner features""" + @staticmethod + def scan(email: str, report_manager: ReportManager): + report_manager.write(f"\n[*] Gathering informations for {email}...") + try: + headers = {"User-Agent": "SpectraScan/1.0"} + try: + result = subprocess.run( + ["curl", "-s", "-H", f"User-Agent: {headers['User-Agent']}", + f"https://emailrep.io/{email}"], + capture_output=True, + text=True, + timeout=15, + ) + payload = result.stdout + except FileNotFoundError: + req = Request(f"https://emailrep.io/{email}", headers=headers) + with urlopen(req, timeout=10) as resp: + payload = resp.read().decode("utf-8", errors="ignore") + + json_data = json.loads(payload) if payload else {} + + details = json_data.get("details", {}) or {} + report_manager.write("\n_____FULL REPORT_____") + report_manager.write(f"-Email: {email}") + report_manager.write(f"-Suspicious: {json_data.get('suspicious')}") + report_manager.write(f"-Reputation: {json_data.get('reputation')}") + report_manager.write(f"-Blacklisted: {details.get('blacklisted')}") + report_manager.write(f"-Malicious Activity: {details.get('malicious_activity')}") + report_manager.write(f"-Data Breach: {details.get('data_breach')}") + report_manager.write(f"-First Seen: {details.get('first_seen')}") + report_manager.write(f"-Last Seen: {details.get('last_seen')}") + report_manager.write(f"-Domain Exists: {details.get('domain_exists')}") + report_manager.write(f"-Free Provider: {details.get('free_provider')}") + report_manager.write(f"-Disposable: {details.get('disposable')}") + report_manager.write(f"-Deliverable: {details.get('deliverable')}") + report_manager.write(f"-Spoofable: {details.get('spoofable')}") + except Exception as e: + report_manager.write(f"Error scraping emailrep: {e}") + report_manager.write("\n[*] DONE") + +# ============== Image Scanner ============== +class ImageScanner: + """Integrates SpectraScan Image EXIF Scanner features""" + @staticmethod + def scan(image_path: str, report_manager: ReportManager): + report_manager.write(f"\nEXIF data from: {image_path}") + if not os.path.exists(image_path): + report_manager.write(f"[-] File not found: {image_path}") + report_manager.write("\n[*] DONE") + return + try: + # Try exiv2 first, then exiftool + exiv2_path = "/usr/bin/exiv2" if sys.platform != "win32" else r"C:\Program Files\exiv2\exiv2.exe" + exiftool_path = "/usr/bin/exiftool" if sys.platform != "win32" else r"C:\Windows\exiftool.exe" + if os.path.exists(exiv2_path): + result = subprocess.run( + ["exiv2", image_path], capture_output=True, text=True, timeout=30 + ) + report_manager.write(result.stdout) + elif os.path.exists(exiftool_path): + result = subprocess.run( + ["exiftool", image_path], capture_output=True, text=True, timeout=30 + ) + report_manager.write(result.stdout) + else: + report_manager.write("No EXIF tool found (exiv2 or exiftool required).") + except FileNotFoundError: + report_manager.write("No EXIF tool found (exiv2 or exiftool required).") + except Exception as e: + report_manager.write(f"Error reading EXIF: {e}") + report_manager.write("\n[*] DONE") + +# ============== Link Scanner ============== +class LinkScanner: + """Integrates SpectraScann Link Sniffing features""" + @staticmethod + def scan(domain: str, report_manager: ReportManager): + report_manager.write(f"\n__________Link Sniffing__________\n") + report_manager.write(f"[*] SNIFFING LINKS for {domain}") + report_manager.write("-------------------------------------------------------------------------------") + try: + result = subprocess.run( + ["curl", "-s", f"https://api.hackertarget.com/pagelinks/?q={domain}"], + capture_output=True, + text=True, + timeout=20, + ) + report_manager.write(result.stdout or "[-] No response from API.") + except FileNotFoundError: + report_manager.write("[-] 'curl' not found.") + except Exception as e: + report_manager.write(f"Error with link sniffing: {e}") + report_manager.write("\n[*] DONE") + +# ============== Criminal Scanner ============== +class CriminalScanner: + """Integrates SpectraScan Criminal Scanner features""" + @staticmethod + def scan(first_name: str, last_name: str, state: str, city: str, report_manager: ReportManager): + state_prefix = f"{state}." if state else "" + link = f"https://{state_prefix}staterecords.org/search.php?firstname={first_name}&lastname={last_name}&city={city}" + report_manager.write("\n[*] Generating Link...") + report_manager.write(f"\n\nCTRL + click on this link to get your report: [{link}]") + report_manager.write("\n[*] DONE") + +# ============== CLI ============== +def print_banner(): + """Prints the SpectraScan Title Banner""" + banner_text = """ + ================================================ + SPECTRASCAN - ENHANCED EDITION + ================================================ + """ + console.print(Panel(banner_text, border_style="cyan", style="bold cyan")) + console.print("[*] Initializing SpectraScan Core...", style="green") + time.sleep(0.5) + console.print("[+] Core Loaded. Awaiting Input.\n", style="green") + + +def guard_interrupt(fn): + """Decorator: catch Ctrl+C / Ctrl+D inside a submenu and bounce + back to the caller instead of killing the whole program.""" + @wraps(fn) + def wrapper(*args, **kwargs): + try: + return fn(*args, **kwargs) + except (KeyboardInterrupt, EOFError): + console.print("\n[!] Cancelled. Returning to previous menu.", style="yellow") + return None + return wrapper + + +def hacker_input(prompt_text: str, default: str = "") -> str: + """Simulates a terminal input with nice formatting""" + if default: + prompt = f"{CYAN}root@spectra:~#{RESET} {prompt_text} [{default}]: " + else: + prompt = f"{CYAN}root@spectra:~#{RESET} {prompt_text}: " + + user_input = input(prompt).strip() + return user_input if user_input else default + + +def parse_ports(ports_input: str): + """Turn a ports string into a list[int], or None if invalid. + Supports comma lists, ranges (80-100), 'all', and 'common'.""" + ports_input = ports_input.lower().strip() + + if ports_input == "all": + return list(range(1, 65536)) + if ports_input == "common": + return list(COMMON_PORTS.keys()) + + ports = set() + try: + for chunk in ports_input.split(","): + chunk = chunk.strip() + if not chunk: + continue + if "-" in chunk: + start, end = chunk.split("-", 1) + start, end = int(start), int(end) + if not (1 <= start <= end <= 65535): + raise ValueError + ports.update(range(start, end + 1)) + else: + p = int(chunk) + if not (1 <= p <= 65535): + raise ValueError + ports.add(p) + except ValueError: + return None + + return sorted(ports) if ports else None + + +def run_network_port_scan( + network_target: str, scan_kwargs: dict, exclusions=None, + max_targets: int = 1024, host_workers: int = 4, + large_scan_confirmed: bool = False, +): + """Scan a bounded CIDR using a producer thread and fixed worker pool.""" + try: + network = ipaddress.ip_network(network_target, strict=False) + except ValueError as exc: + raise ValueError(f"Invalid network: {exc}") from exc + if isinstance(max_targets, bool) or not 1 <= int(max_targets) <= 65536: + raise ValueError("max_targets must be between 1 and 65536") + if isinstance(host_workers, bool) or not 1 <= int(host_workers) <= 16: + raise ValueError("host_workers must be between 1 and 16") + if network.num_addresses > int(max_targets): + raise ValueError(f"Network contains {network.num_addresses} addresses; configured maximum is {max_targets}.") + if network.num_addresses > 64 and not large_scan_confirmed: + raise ValueError("Large network scan requires explicit confirmation (--yes).") + excluded_networks = [] + for value in exclusions or []: + try: + excluded_networks.append(ipaddress.ip_network(value, strict=False)) + except ValueError as exc: + raise ValueError(f"Invalid exclusion network: {value}") from exc + for excluded in excluded_networks: + if excluded.version != network.version: + raise ValueError(f"Exclusion {excluded} uses a different IP version from {network}.") + work_queue = queue.Queue(maxsize=max(2, host_workers * 2)) + sentinel = object() + results = [] + errors = [] + result_lock = threading.Lock() + + def producer(): + try: + for address in network.hosts(): + if any(address in excluded for excluded in excluded_networks): + continue + work_queue.put(str(address)) + except Exception as exc: + errors.append(f"{type(exc).__name__}: {exc}") + finally: + for _ in range(host_workers): + work_queue.put(sentinel) + + def worker(): + while True: + item = work_queue.get() + try: + if item is sentinel: + return + ip_text = str(item) + try: + scanner = PortScanner(ip_text, **scan_kwargs) + scanner.resolved_ip = ip_text + scanner.scan() + if scanner.results: + with result_lock: + results.append({"ip": ip_text, "ports": scanner.results}) + print(f"{GREEN}[+] {ip_text}: {len(scanner.results)} responsive ports") + except Exception as exc: + with result_lock: + errors.append(f"{ip_text}: {type(exc).__name__}") + finally: + work_queue.task_done() + + workers = [ + threading.Thread(target=worker, name=f"spectrascan-host-{i}", daemon=True) + for i in range(host_workers) + ] + for thread in workers: + thread.start() + producer_thread = threading.Thread(target=producer, name="spectrascan-host-producer", daemon=True) + producer_thread.start() + producer_thread.join() + work_queue.join() + for thread in workers: + thread.join() + if errors: + console.print(f"[yellow][!] {len(errors)} host scan error(s) occurred.[/yellow]") + results.sort(key=lambda item: ipaddress.ip_address(item["ip"])) + return results + + +@guard_interrupt +def run_port_scan_cli(): + """Interactive Port Scan Menu.""" + + console.print( + "\n[bold cyan]--- PORT SCANNER MODULE ---[/bold cyan]" + ) + + target = hacker_input( + "Enter Target IP, CIDR, Hostname, or URL" + ) + + if not target: + console.print( + "[!] Target required. Returning to menu.", + style="red", + ) + return + + target = normalize_target(target) + + if not target: + console.print( + "[!] Invalid target.", + style="red", + ) + return + + scan_type = Prompt.ask( + "Scan Type", + choices=["tcp", "syn", "udp"], + default="tcp", + ) + + timing = Prompt.ask( + "Timing Profile", + choices=["T0", "T1", "T2", "T3", "T4", "T5"], + default="T3", + ) + + ports_input = hacker_input( + "Enter Ports (e.g. 80,443,8080 or 1-1024), " + "'common', or 'all'", + "common", + ) + + ports = parse_ports(ports_input) + + if ports is None: + console.print( + "[!] Invalid port format. Returning to menu.", + style="red", + ) + return + + if len(ports) > 5000: + proceed = Confirm.ask( + f"[!] {len(ports)} ports selected — this may take a while. Continue?", + default=True, + ) + if not proceed: + console.print( + "[*] Scan cancelled.", + style="yellow", + ) + return + + check_vulns = Confirm.ask( + "Check for Vulnerabilities?", + default=False, + ) + + console.print( + f"\n[+] Spectra Starting Scan on {target}...", + style="bold yellow", + ) + + scan_kwargs = { + "timeout": 1.0, + "threads": 50, + "scan_type": scan_type, + "timing": timing, + "ports": ports, + "decoys": [], + "check_vulns": check_vulns, + "rate_limit": 0, + } + + try: + network = ipaddress.ip_network( + target, + strict=False, + ) + + console.print( + f"[cyan][*] CIDR network detected: {network}[/cyan]" + ) + + run_network_port_scan( + str(network), + scan_kwargs, + ) + return + + except ValueError: + pass + + try: + resolved_ip = resolve_host(target) + + console.print( + f"[cyan][*] Resolved: " + f"{target} -> {resolved_ip}[/cyan]" + ) + except Exception as exc: + console.print( + f"[!] Cannot resolve target: {exc}", + style="bold red", + ) + return + + try: + scanner = PortScanner( + target, + **scan_kwargs, + ) + + scanner.resolved_ip = resolved_ip + scanner.scan() + + except KeyboardInterrupt: + console.print( + "[!] Scan interrupted by user.", + style="yellow", + ) + return + + except Exception as exc: + console.print( + f"[!] Scan failed: {exc}", + style="bold red", + ) + return + + scanner.print_summary() + + if not scanner.results: + console.print( + "[*] No results to export.", + style="yellow", + ) + return + + if not Confirm.ask( + "Export Results?", + default=True, + ): + return + + fmt = Prompt.ask( + "Format", + choices=["json", "html", "csv"], + default="json", + ) + + filename = hacker_input( + "Filename (without extension)", + "scan_report", + ) + + filename = "".join( + char + for char in filename + if char.isalnum() or char in ("_", "-") + ) or "scan_report" + + full_path = f"{filename}.{fmt}" + + try: + if fmt == "json": + scanner.export_json(full_path) + elif fmt == "html": + scanner.export_html(full_path) + else: + scanner.export_csv(full_path) + + console.print( + f"[+] Results exported to {full_path}", + style="green", + ) + + except Exception as exc: + console.print( + f"[!] Export failed: {exc}", + style="bold red", + ) + +@guard_interrupt +def run_other_scanners(): + """Interactive menu for Domain, IP, Email, etc.""" + console.print("\n[bold cyan]--- ADVANCED MODULES ---[/bold cyan]") + mode = Prompt.ask("Select Module", choices=[ + "domain", "ip", "phone", "email", "image", "link", "criminal", "reports" + ]) + + rm = ReportManager() + + module_map = { + "domain": ("Enter Domain", DomainScanner), + "ip": ("Enter IP Address", IPScanner), + "phone": ("Enter Phone Number", PhoneScanner), + "email": ("Enter Email Address", EmailScanner), + "image": ("Enter Image Path", ImageScanner), + "link": ("Enter Domain for Link Sniffing", LinkScanner), + } + + if mode in module_map: + prompt_text, scanner_cls = module_map[mode] + value = hacker_input(prompt_text) + if not value: + console.print("[!] Input required. Returning to menu.", style="red") + return + try: + scanner_cls.scan(value, rm) + except Exception as e: + console.print(f"[!] {mode.capitalize()} scan failed: {e}", style="bold red") + + elif mode == "criminal": + first = hacker_input("First Name") + last = hacker_input("Last Name") + if not first or not last: + console.print("[!] First and last name are required.", style="red") + return + state = hacker_input("State (Optional)") + city = hacker_input("City") + try: + CriminalScanner.scan(first, last, state, city, rm) + except Exception as e: + console.print(f"[!] Criminal lookup failed: {e}", style="bold red") + + elif mode == "reports": + action = Prompt.ask("Action", choices=["read", "delete"]) + try: + if action == "read": + rm.read_report() + elif action == "delete": + if Confirm.ask("Are you sure you want to delete a report?", default=False): + rm.delete_report() + else: + console.print("[*] Delete cancelled.", style="yellow") + except Exception as e: + console.print(f"[!] Report action failed: {e}", style="bold red") + + +MENU_ACTIONS = { + "1": ("Port Scanner", run_port_scan_cli), + "2": ("Advanced Modules (Domain/IP/Email/etc)", run_other_scanners), + "3": ("Protocol Modules (SMB/SNMP/LDAP/RDP/etc)", lambda: run_protocol_modules()), +} + + +def print_main_menu(): + console.print("\n[bold]MAIN MENU[/bold]") + for key, (label, _) in MENU_ACTIONS.items(): + style = "bold green" if key in ("1", "2") else "bold magenta" + console.print(f"[{style}]{key}.[/{style}] {label}") + console.print("[bold red]4.[/bold red] Exit") + + +def main(): + # Legacy CLI args for backward compatibility / scripting + if len(sys.argv) > 1: + parser = argparse.ArgumentParser(description="SpectraScan Legacy CLI") parser.add_argument("-t", "--target", help="Target") parser.add_argument("-d", "--domain", help="Domain") parser.add_argument("-i", "--ip", help="IP") @@ -6900,33 +6814,33 @@ def main(): scanner.resolved_ip = resolved_ip scanner.scan() scanner.print_summary() - except Exception as exc: - console.print( - f"[!] Scan failed: {exc}", - style="bold red", - ) - sys.exit(1) - - return - - print_banner() - - while True: - print_main_menu() - try: - choice = input(f"{CYAN}root@spectra:~#{RESET} Select Option: ").strip() - except (KeyboardInterrupt, EOFError): - console.print("\n[*] Exiting SpectraScan. Stay Anonymous.", style="yellow") - break - - if choice == "4": - console.print("[*] Exiting SpectraScan. Stay Anonymous.", style="yellow") - break - elif choice in MENU_ACTIONS: - MENU_ACTIONS[choice][1]() - else: - console.print("[!] Invalid Option. Please try again.", style="red") - - -if __name__ == "__main__": + except Exception as exc: + console.print( + f"[!] Scan failed: {exc}", + style="bold red", + ) + sys.exit(1) + + return + + print_banner() + + while True: + print_main_menu() + try: + choice = input(f"{CYAN}root@spectra:~#{RESET} Select Option: ").strip() + except (KeyboardInterrupt, EOFError): + console.print("\n[*] Exiting SpectraScan. Stay Anonymous.", style="yellow") + break + + if choice == "4": + console.print("[*] Exiting SpectraScan. Stay Anonymous.", style="yellow") + break + elif choice in MENU_ACTIONS: + MENU_ACTIONS[choice][1]() + else: + console.print("[!] Invalid Option. Please try again.", style="red") + + +if __name__ == "__main__": main() \ No newline at end of file From fe3872f975e05ebccd85c6eae09c296d242ddf35 Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 19:02:18 +0330 Subject: [PATCH 06/46] fix: implement strict CLI routing and report options --- SpectraScan.py | 234 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 195 insertions(+), 39 deletions(-) diff --git a/SpectraScan.py b/SpectraScan.py index 1c19dc9..70dd8c2 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -6784,47 +6784,203 @@ def print_main_menu(): console.print("[bold red]4.[/bold red] Exit") -def main(): - # Legacy CLI args for backward compatibility / scripting - if len(sys.argv) > 1: - parser = argparse.ArgumentParser(description="SpectraScan Legacy CLI") - parser.add_argument("-t", "--target", help="Target") - parser.add_argument("-d", "--domain", help="Domain") - parser.add_argument("-i", "--ip", help="IP") - parser.add_argument("--async-scan", action="store_true", help="Use the asyncio/aiohttp scanner engine for TCP scans") - parser.add_argument("--async-concurrency", type=int, default=100, help="Maximum concurrent async connections (default: 100)") - parser.add_argument("--async-timeout", type=float, default=1.0, help="Async connection timeout in seconds (default: 1.0)") - args, _ = parser.parse_known_args() - - if args.target: +def _cli_parser(): + parser = argparse.ArgumentParser( + description="SpectraScan — authorized network reconnaissance and reporting", + allow_abbrev=False, + ) + targets = parser.add_mutually_exclusive_group(required=False) + targets.add_argument("-t", "--target", help="IP address, hostname, URL, or CIDR network") + targets.add_argument("-d", "--domain", help="Run passive-first domain intelligence") + targets.add_argument("-i", "--ip", help="Run IP intelligence") + parser.add_argument("-p", "--ports", default="common", help="Ports: comma list, range (1-1024), common, or all") + parser.add_argument("-T", "--timing", choices=sorted(TIMING_PROFILES), default="T3", help="Port-scan timing profile") + parser.add_argument("--scan-type", choices=("tcp", "syn", "udp"), default="tcp", help="Select TCP connect, SYN, or UDP probing") + parser.add_argument("--timeout", type=float, default=None, help="Override connection timeout in seconds") + parser.add_argument("--threads", type=int, default=100, help="Port worker count (1-512)") + parser.add_argument("--async-scan", action="store_true", help="Use the bounded asyncio TCP scanner") + parser.add_argument("--async-concurrency", type=int, default=100, help="Async connections (1-512)") + parser.add_argument("--async-timeout", type=float, default=1.0, help="Async connection timeout (0-120 seconds)") + parser.add_argument("--vuln-scan", action="store_true", help="Enable built-in service/port vulnerability heuristics") + parser.add_argument("--os-detect", action="store_true", help="Record TTL-based OS fingerprint evidence") + parser.add_argument("--web-enum", action="store_true", help="Enumerate web paths using --wordlist") + parser.add_argument("--wordlist", help="Wordlist for --web-enum") + parser.add_argument("-o", "--output", help="Write a report to this path") + parser.add_argument("-f", "--format", choices=("json", "html", "csv"), help="Report format; inferred from output extension if omitted") + parser.add_argument("--exclude", action="append", default=[], help="Exclude an IP/CIDR from a network scan; repeatable") + parser.add_argument("--max-targets", type=int, default=1024, help="Hard cap on CIDR address count (1-65536)") + parser.add_argument("--yes", action="store_true", help="Confirm large scans and port ranges explicitly") + return parser + + +def _write_cli_report(report, output_path, fmt): + fmt = fmt or os.path.splitext(output_path)[1].lstrip(".").lower() + if fmt == "json": + return atomic_write_json(output_path, report) + if fmt == "html": + pretty = html.escape(json.dumps(report, indent=2, ensure_ascii=False, default=str)) + document = "SpectraScan Report

SpectraScan Report

" + pretty + "
" + return atomic_write_text(output_path, document) + if fmt != "csv": + raise ValueError("Report format must be json, html, or csv") + stream = io.StringIO(newline="") + writer = csv.writer(stream) + writer.writerow(["Target", "Port", "Protocol", "State", "Service", "Banner", "Classification"]) + rows = list(report.get("open_ports", [])) + for host in report.get("hosts", []): + for port in host.get("ports", []): + rows.append({"target": host.get("ip"), **port}) + for item in rows: + service = item.get("service", "") + if isinstance(service, tuple): + service = " ".join(str(part) for part in service if part) + writer.writerow([ + safe_csv_cell(item.get("target", report.get("target", ""))), + safe_csv_cell(item.get("port", "")), + safe_csv_cell(item.get("protocol", report.get("scan_type", "tcp"))), + safe_csv_cell(item.get("state", "")), safe_csv_cell(service), + safe_csv_cell((item.get("banner", "") or "")[:500]), + safe_csv_cell(item.get("classification", "")), + ]) + return atomic_write_text(output_path, stream.getvalue()) + + +def main(argv: Optional[List[str]] = None): + if argv is None: + argv = sys.argv[1:] + # Keep the interactive menu for an argument-free launch. + if argv: + parser = _cli_parser() + args = parser.parse_args(argv) + if not any((args.target, args.domain, args.ip)): + parser.error("choose one target with -t/--target, -d/--domain, or -i/--ip") + if not 1 <= args.max_targets <= 65536: + parser.error("--max-targets must be between 1 and 65536") + if not 1 <= args.threads <= 512: + parser.error("--threads must be between 1 and 512") + if args.timeout is not None and (not math.isfinite(args.timeout) or not 0 < args.timeout <= 120): + parser.error("--timeout must be greater than 0 and at most 120 seconds") + if args.async_scan and args.scan_type != "tcp": + parser.error("--async-scan supports only --scan-type tcp") + if args.async_scan and args.timeout is not None: + parser.error("use --async-timeout with --async-scan instead of --timeout") + if args.web_enum and not args.wordlist: + parser.error("--web-enum requires --wordlist") + if args.format and not args.output: + parser.error("--format requires --output") + if args.output and not args.format and os.path.splitext(args.output)[1].lower() not in {".json", ".html", ".csv"}: + parser.error("cannot infer report format; specify --format json|html|csv") + if args.output and args.format and os.path.splitext(args.output)[1] and os.path.splitext(args.output)[1].lower() != "." + args.format: + parser.error("--format conflicts with output file extension") + + manager = ReportManager() + if args.domain: + report = DomainScanner.scan(args.domain, manager) + if report is None: + parser.error("domain scan could not validate the requested domain") + if args.output: + saved = _write_cli_report(report, args.output, args.format or "json") + console.print(f"[green][+] Report saved: {saved}[/green]") + return 0 + if args.ip: + try: + ipaddress.ip_address(args.ip) + except ValueError: + parser.error("--ip must be a valid IPv4 or IPv6 address") + IPScanner.scan(args.ip, manager) + report = { + "module": "IP Intelligence", "target": args.ip, + "timestamp": datetime.now().isoformat(), "output": manager._buffer, + } + if args.output: + saved = _write_cli_report(report, args.output, args.format or "json") + console.print(f"[green][+] Report saved: {saved}[/green]") + return 0 + + target = normalize_target(args.target) + if not target: + parser.error("target cannot be empty") + try: + network = ipaddress.ip_network(target, strict=False) + is_network = "/" in target + except ValueError: + network = None + is_network = False + if args.scan_type == "udp" and args.ports.strip().lower() == "common": + ports = sorted(UDPScan().common_udp_ports) + else: + ports = parse_ports(args.ports) + if ports is None: + parser.error(f"invalid --ports value: {args.ports!r}; use comma-separated ports, ranges, common, or all") + if len(ports) > 5000 and not args.yes: + parser.error("large port ranges require explicit confirmation; add --yes") + if args.async_scan: + if not 1 <= args.async_concurrency <= 512: + parser.error("--async-concurrency must be between 1 and 512") + if not math.isfinite(args.async_timeout) or not 0 < args.async_timeout <= 120: + parser.error("--async-timeout must be greater than 0 and at most 120 seconds") + if len(ports) > 10000 and not args.yes: + parser.error("async scans over 10000 ports require --yes") + + scan_kwargs = { + "timeout": args.timeout if args.timeout is not None else 1.0, + "threads": args.threads, + "scan_type": args.scan_type, + "timing": "custom" if args.timeout is not None else args.timing, + "ports": ports, "decoys": [], "check_vulns": args.vuln_scan, "rate_limit": 0, + } + if is_network: + if network.version != 4: + parser.error("CIDR port sweeps currently support IPv4 only; use a single IPv6 target") + if network.num_addresses > args.max_targets: + parser.error(f"network has {network.num_addresses} addresses, exceeding --max-targets={args.max_targets}") + if network.num_addresses > 64 and not args.yes: + parser.error("large network scan requires explicit confirmation; add --yes") + results = run_network_port_scan( + str(network), scan_kwargs, exclusions=args.exclude, + max_targets=args.max_targets, large_scan_confirmed=args.yes, + ) + report = { + "target": str(network), "scan_type": args.scan_type, + "timestamp": datetime.now().isoformat(), "hosts": results, "open_ports": [], + } + else: try: - target = normalize_target(args.target) resolved_ip = resolve_host(target) - - if args.async_scan: - scanner = AsyncPortScanner(target, ports=list(COMMON_PORTS.keys()), timeout=args.async_timeout, concurrency=args.async_concurrency) - scanner.resolved_ip = resolved_ip - asyncio.run(scanner.scan_async()) - result = scanner.get_results() - console.print(f"[green][✓] Async scan completed in {result['duration']:.2f}s — {len(result['open_ports'])} open ports[/green]") - for item in sorted(result["open_ports"], key=lambda value: value["port"]): - console.print(f"[green][+] {item['port']}/tcp {item.get('service', 'unknown')} {item.get('banner', '')[:80]}[/green]") - else: - scanner = PortScanner(target) - scanner.resolved_ip = resolved_ip - scanner.scan() - scanner.print_summary() - except Exception as exc: - console.print( - f"[!] Scan failed: {exc}", - style="bold red", + except (ValueError, socket.gaierror) as exc: + parser.error(str(exc)) + if args.async_scan: + scanner = AsyncPortScanner( + target, ports=ports, timeout=args.async_timeout, + concurrency=args.async_concurrency, ) - sys.exit(1) + try: + scanner.scan() + report = scanner.get_results() + except (ValueError, OSError, asyncio.TimeoutError) as exc: + parser.error(f"async scan failed: {exc}") + else: + scanner = PortScanner(target, **scan_kwargs) + scanner.resolved_ip = resolved_ip + scanner.scan() + report = scanner.get_results() + if args.os_detect: + try: + report["os_fingerprint_ttl"] = OSFingerprint.get_ttl(resolved_ip) + report["os_fingerprint_note"] = "TTL heuristic only; not definitive OS identification." + except Exception: + report["os_fingerprint_ttl"] = None + if args.web_enum: + base_url = target if target.lower().startswith(("http://", "https://")) else "http://" + target + report["web_enumeration"] = WebEnumerator(base_url, args.wordlist).run() + if args.output: + saved = _write_cli_report(report, args.output, args.format or "json") + console.print(f"[green][+] Report saved: {saved}[/green]") + elif args.scan_type == "udp": + console.print("[yellow]UDP silence is reported as open|filtered; it is not proof of an open port.[/yellow]") + return 0 - return - print_banner() - while True: print_main_menu() try: @@ -6832,7 +6988,6 @@ def main(): except (KeyboardInterrupt, EOFError): console.print("\n[*] Exiting SpectraScan. Stay Anonymous.", style="yellow") break - if choice == "4": console.print("[*] Exiting SpectraScan. Stay Anonymous.", style="yellow") break @@ -6840,7 +6995,8 @@ def main(): MENU_ACTIONS[choice][1]() else: console.print("[!] Invalid Option. Please try again.", style="red") - - + return 0 + + if __name__ == "__main__": main() \ No newline at end of file From 4fd05d53202066e74fc2da165de2bd9b4e395726 Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 19:03:34 +0330 Subject: [PATCH 07/46] test: cover test_cli --- tests/test_cli.py | 88 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 tests/test_cli.py diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..dcc51a0 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,88 @@ +"""CLI integration tests for selected ports, output files, and strict parsing.""" +import json +import os +import socket +import subprocess +import sys +import tempfile +import threading +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "SpectraScan.py" + + +class TestCliIntegration(unittest.TestCase): + def _listener(self): + server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + server.listen() + server.settimeout(0.2) + port = server.getsockname()[1] + stop = threading.Event() + + def serve(): + while not stop.is_set(): + try: + conn, _ = server.accept() + except socket.timeout: + continue + except OSError: + break + with conn: + try: + conn.sendall(b"test-service\r\n") + conn.settimeout(0.1) + try: + conn.recv(1024) + except OSError: + pass + except OSError: + pass + + thread = threading.Thread(target=serve, daemon=True) + thread.start() + self.addCleanup(stop.set) + self.addCleanup(server.close) + self.addCleanup(thread.join, 1) + return port + + def _run(self, *args, cwd): + env = os.environ.copy() + env["PYTHONPATH"] = str(ROOT) + os.pathsep + env.get("PYTHONPATH", "") + return subprocess.run( + [sys.executable, str(SCRIPT), *map(str, args)], + cwd=cwd, env=env, text=True, capture_output=True, timeout=30, + ) + + def test_selected_port_is_scanned_and_report_written(self): + port = self._listener() + with tempfile.TemporaryDirectory() as temp: + output = Path(temp) / "scan.json" + result = self._run( + "-t", "127.0.0.1", "-p", str(port), "--scan-type", "tcp", + "--threads", "2", "--timeout", "0.3", "-o", output, cwd=temp, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr + result.stdout) + self.assertTrue(output.exists()) + report = json.loads(output.read_text(encoding="utf-8")) + self.assertEqual([item["port"] for item in report["open_ports"]], [port]) + self.assertEqual(report["scan_type"], "tcp") + + def test_unknown_argument_is_rejected(self): + with tempfile.TemporaryDirectory() as temp: + result = self._run("-t", "127.0.0.1", "--not-a-real-option", cwd=temp) + self.assertNotEqual(result.returncode, 0) + self.assertIn("unrecognized arguments", result.stderr) + + def test_invalid_port_range_is_rejected_before_network_access(self): + with tempfile.TemporaryDirectory() as temp: + result = self._run("-t", "127.0.0.1", "-p", "0,70000", cwd=temp) + self.assertNotEqual(result.returncode, 0) + self.assertIn("invalid --ports", result.stderr.lower()) + + +if __name__ == "__main__": + unittest.main() From fa25fd881dba4a5253f79d52d682834296515fe8 Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 19:03:37 +0330 Subject: [PATCH 08/46] test: cover test_brute_forcer --- tests/test_brute_forcer.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/test_brute_forcer.py diff --git a/tests/test_brute_forcer.py b/tests/test_brute_forcer.py new file mode 100644 index 0000000..397a1dd --- /dev/null +++ b/tests/test_brute_forcer.py @@ -0,0 +1,38 @@ +"""Regression tests for deterministic workers and secret-safe results.""" +import tempfile +import unittest +from pathlib import Path + +from modules.brute_forcer import BruteForcer + + +class FakeBruteForcer(BruteForcer): + def check_ssh(self, username, password): + if password == "match": + return {"status": "success", "category": "authenticated", "username": username, "password": password} + return {"status": "failed", "category": "invalid_credentials", "username": username} + + +class TestBruteForcer(unittest.TestCase): + def test_queue_is_filled_before_workers_and_passwords_are_not_retained(self): + with tempfile.TemporaryDirectory() as temp: + wordlist = Path(temp) / "words.txt" + wordlist.write_text("wrong\nmatch\n", encoding="utf-8") + scanner = FakeBruteForcer("127.0.0.1", 22, "ssh", str(wordlist), max_threads=4) + results = scanner.run() + self.assertEqual(len(results), 2) + self.assertTrue(all("password" not in item and "pass" not in item for item in results)) + self.assertEqual({item["username"] for item in results}, {"admin", "root"}) + + def test_unknown_service_and_invalid_port_rejected(self): + with tempfile.TemporaryDirectory() as temp: + wordlist = Path(temp) / "words.txt" + wordlist.write_text("x", encoding="utf-8") + with self.assertRaises(ValueError): + BruteForcer("localhost", 0, "ssh", str(wordlist)) + with self.assertRaises(ValueError): + BruteForcer("localhost", 22, "telnet", str(wordlist)) + + +if __name__ == "__main__": + unittest.main() From 43381c0003768d95edb0e8ee566b84fcd001e7ff Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 19:03:39 +0330 Subject: [PATCH 09/46] test: cover test_vuln_scanner --- tests/test_vuln_scanner.py | 66 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/test_vuln_scanner.py diff --git a/tests/test_vuln_scanner.py b/tests/test_vuln_scanner.py new file mode 100644 index 0000000..c5c745e --- /dev/null +++ b/tests/test_vuln_scanner.py @@ -0,0 +1,66 @@ +"""Mocked CVE parsing/correlation tests; no live NVD requests.""" +import unittest +from unittest.mock import Mock + +from modules.vuln_scanner import VulnScanner + + +class TestVulnScanner(unittest.TestCase): + def test_missing_cvss_metrics_do_not_crash(self): + session = Mock() + response = Mock() + response.status_code = 200 + response.headers = {} + response.raise_for_status.return_value = None + response.json.return_value = { + "totalResults": 1, + "vulnerabilities": [{ + "cve": { + "id": "CVE-2025-0001", + "descriptions": [{"lang": "en", "value": "Example CVE"}], + "metrics": {}, + "configurations": [], + } + }], + } + session.get.return_value = response + scanner = VulnScanner("openssh", "9.1", session=session, max_records=10) + found = scanner.check_nvd_api() + self.assertEqual(found[0]["severity"], "UNKNOWN") + self.assertIn(found[0]["classification"], {"potential", "informational", "confirmed"}) + + def test_exact_cpe_version_range_classified_confirmed(self): + scanner = VulnScanner("openssh", "9.1", vendor="openbsd", product="openssh") + cve = { + "configurations": [{ + "nodes": [{ + "cpeMatch": [{ + "vulnerable": True, + "criteria": "cpe:2.3:a:openbsd:openssh:9.1:*:*:*:*:*:*:*", + }] + }] + }] + } + classification, evidence = scanner._match_evidence(cve) + self.assertEqual(classification, "confirmed") + self.assertTrue(evidence) + + def test_keyword_only_match_is_not_confirmed(self): + scanner = VulnScanner("ssh") + classification, evidence = scanner._match_evidence({"configurations": []}) + self.assertEqual(classification, "informational") + self.assertTrue(evidence) + + def test_rate_limit_returns_structured_error(self): + session = Mock() + response = Mock() + response.status_code = 403 + response.headers = {} + session.get.return_value = response + scanner = VulnScanner("ssh", session=session) + self.assertEqual(scanner.check_nvd_api(), []) + self.assertEqual(scanner.errors[0]["category"], "rate_limited") + + +if __name__ == "__main__": + unittest.main() From c3f64e8fb46aadcf2ea825e81fb8ae66fbc29193 Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 19:03:42 +0330 Subject: [PATCH 10/46] test: cover test_report_store --- tests/test_report_store.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/test_report_store.py diff --git a/tests/test_report_store.py b/tests/test_report_store.py new file mode 100644 index 0000000..e175563 --- /dev/null +++ b/tests/test_report_store.py @@ -0,0 +1,35 @@ +"""Atomic report store and spreadsheet-safe export tests.""" +import json +import tempfile +import unittest +from pathlib import Path + +from modules.report_store import ReportStore, safe_csv_cell + + +class TestReportStore(unittest.TestCase): + def test_atomic_json_write_and_history(self): + with tempfile.TemporaryDirectory() as temp: + store = ReportStore(temp) + path = store.save_json("SS-report.json", {"ok": True}) + self.assertTrue(Path(path).exists()) + self.assertEqual(json.loads(Path(path).read_text(encoding="utf-8")), {"ok": True}) + self.assertIn("SS-report.json", store.list_reports()) + self.assertEqual(store.read_text("SS-report.json").strip()[0], "{") + store.delete("SS-report.json") + self.assertNotIn("SS-report.json", store.list_reports()) + + def test_path_traversal_is_rejected(self): + with tempfile.TemporaryDirectory() as temp: + store = ReportStore(temp) + with self.assertRaises(ValueError): + store.read_text("../secret.txt") + + def test_formula_leading_csv_cells_are_neutralized(self): + for value in ("=1+1", "+cmd", "-2+3", "@SUM(A1:A2)", " =1+1"): + with self.subTest(value=value): + self.assertTrue(safe_csv_cell(value).lstrip().startswith("'")) + + +if __name__ == "__main__": + unittest.main() From 13ddf9d67a49e971a777ad5c34fa5e7b3a96a75e Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 19:03:44 +0330 Subject: [PATCH 11/46] test: cover test_network_sweep --- tests/test_network_sweep.py | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 tests/test_network_sweep.py diff --git a/tests/test_network_sweep.py b/tests/test_network_sweep.py new file mode 100644 index 0000000..39a297f --- /dev/null +++ b/tests/test_network_sweep.py @@ -0,0 +1,40 @@ +"""Resource-limit and exclusion tests for CIDR scan orchestration.""" +import unittest +from unittest.mock import patch + +import SpectraScan + + +class TestNetworkSweep(unittest.TestCase): + def test_large_ranges_require_confirmation_before_iteration(self): + with self.assertRaisesRegex(ValueError, "explicit confirmation"): + SpectraScan.run_network_port_scan("192.168.1.0/24", {"ports": [80]}, max_targets=1024) + + def test_address_count_hard_cap_is_enforced(self): + with self.assertRaisesRegex(ValueError, "configured maximum"): + SpectraScan.run_network_port_scan("192.168.0.0/16", {"ports": [80]}, max_targets=1024, large_scan_confirmed=True) + + def test_excluded_network_is_not_scanned(self): + seen = [] + + class FakeScanner: + def __init__(self, target, **kwargs): + self.target = target + self.results = [] + + def scan(self): + seen.append(self.target) + self.results = [{"port": 80, "state": "open"}] + + with patch.object(SpectraScan, "PortScanner", FakeScanner): + rows = SpectraScan.run_network_port_scan( + "192.0.2.0/30", {"ports": [80], "threads": 1}, + exclusions=["192.0.2.1/32"], max_targets=8, + large_scan_confirmed=True, host_workers=2, + ) + self.assertEqual({row["ip"] for row in rows}, {"192.0.2.2"}) + self.assertNotIn("192.0.2.1", seen) + + +if __name__ == "__main__": + unittest.main() From c5736e08279bf409adb0e3902f60461466fce2c3 Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 19:04:57 +0330 Subject: [PATCH 12/46] test: cover async scanner input validation --- tests/test_async_scanner.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_async_scanner.py b/tests/test_async_scanner.py index 1c568e8..8503a63 100644 --- a/tests/test_async_scanner.py +++ b/tests/test_async_scanner.py @@ -12,6 +12,20 @@ def test_invalid_configuration(self): with self.assertRaises(ValueError): AsyncPortScanner("127.0.0.1", ports=[80], concurrency=0) + def test_invalid_ports_are_rejected(self): + for invalid in (0, -1, 65536, "abc"): + with self.subTest(invalid=invalid): + with self.assertRaises(ValueError): + AsyncPortScanner("127.0.0.1", ports=[invalid]) + + def test_empty_ports_are_rejected(self): + with self.assertRaises(ValueError): + AsyncPortScanner("127.0.0.1", ports=[]) + + def test_concurrency_upper_bound_is_enforced(self): + with self.assertRaises(ValueError): + AsyncPortScanner("127.0.0.1", ports=[80], concurrency=513) + def test_async_tcp_scan_finds_local_listener(self): async def scenario(): async def handle(reader, writer): From 64431c1f39a2feb9c6d7be9e02c7789989315235 Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 19:05:00 +0330 Subject: [PATCH 13/46] build: declare TLS certificate parsing dependency --- requirements.txt | 73 ++++++++++++++++++++++++------------------------ 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/requirements.txt b/requirements.txt index 24a36b7..4aee8a3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,37 +1,38 @@ -# ============================================================ -# SpectraScan — Core Dependencies -# ============================================================ - -dnspython -colorama -prettytable -scapy -python-nmap -paramiko -requests -fpdf -pyyaml +# ============================================================ +# SpectraScan — Core Dependencies +# ============================================================ + +dnspython +colorama +prettytable +scapy +python-nmap +paramiko +requests +fpdf +pyyaml rich>=13.0 -aiohttp>=3.9 -impacket>=0.10.0 - - -# ============================================================ -# Domain Intelligence / OSINT -# ============================================================ - -# WHOIS registration intelligence -python-whois - - -# ============================================================ -# Dark Web Recon Module (modules/darkweb.py) -# ============================================================ - -# Tor SOCKS5 support for .onion resolution -# and hidden-service banner collection. -# -# Clearnet functionality such as Ahmia, -# PGP, BTC and email reputation does not -# require Tor. -pysocks \ No newline at end of file +aiohttp>=3.9 +impacket>=0.10.0 + + +# ============================================================ +# Domain Intelligence / OSINT +# ============================================================ + +# WHOIS registration intelligence +python-whois + + +# ============================================================ +# Dark Web Recon Module (modules/darkweb.py) +# ============================================================ + +# Tor SOCKS5 support for .onion resolution +# and hidden-service banner collection. +# +# Clearnet functionality such as Ahmia, +# PGP, BTC and email reputation does not +# require Tor. +pysocks +cryptography>=41.0 From e8ac8813077b8c4a4ca868e6bd328cb19ccd533b Mon Sep 17 00:00:00 2001 From: ItsWanheda Date: Thu, 22 Oct 2026 19:05:03 +0330 Subject: [PATCH 14/46] docs: document hardened CLI and report behavior --- README.md | 1701 +++++++++++++++++++++++++++-------------------------- 1 file changed, 858 insertions(+), 843 deletions(-) diff --git a/README.md b/README.md index c9438df..f9fe37b 100644 --- a/README.md +++ b/README.md @@ -1,731 +1,731 @@ -
- -# 🕵️‍♂️ SpectraScan - -### Advanced Network Reconnaissance, Port Scanning & OSINT Intelligence Framework - -

- - License - - Python - Platform - Status - Black - PRs Welcome -

- -

- One modular CLI for network scanning, service enumeration, OSINT, - vulnerability correlation, protocol intelligence, and passive dark-web reconnaissance. -

- -

- Why SpectraScan · - Features · - Installation · - Quick Start · - Architecture · - Roadmap -

- -
- ---- - -## 📑 Table of Contents - -- [🎯 Why SpectraScan?](#-why-spectrascan) -- [✨ Features](#-features) - - [Core Scanning Engine](#-core-scanning-engine) - - [OSINT Intelligence Suite](#-osint-intelligence-suite) - - [Attack & Vulnerability Modules](#-attack--vulnerability-modules) - - [Protocol Enumeration](#-protocol-enumeration) - - [Dark Web Recon](#-dark-web-recon) - - [Report Management](#-report-management) -- [🏗️ Architecture](#-architecture) -- [📦 Installation](#-installation) -- [⚡ Quick Start](#-quick-start) -- [🚀 Usage](#-usage) -- [🧭 CLI Menu](#-cli-menu) -- [⚙️ Configuration](#-configuration) -- [📊 Output Examples](#-output-examples) -- [🧩 Project Structure](#-project-structure) -- [⚡ Performance & Threading](#-performance--threading) -- [🌍 Environment Variables](#-environment-variables) -- [🆚 Comparison](#-comparison) -- [🩺 Troubleshooting](#-troubleshooting) -- [🛣️ Roadmap](#-roadmap) -- [🛡️ Security Notes](#-security-notes) -- [🤝 Contributing](#-contributing) -- [🙏 Acknowledgements](#-acknowledgements) -- [⚠️ Disclaimer](#-disclaimer) -- [📜 License](#-license) - ---- - -## 🎯 Why SpectraScan? - -Reconnaissance usually means switching between several tools, formats, terminals, -and output styles. **SpectraScan** brings a broad collection of reconnaissance -and enumeration workflows into one modular Python CLI. - -| Need | Typical Tooling | SpectraScan | -|---|---|---| -| Fast port scanning | Nmap, Masscan | `core.scanner` | -| Web fingerprinting | WhatWeb, Nikto | `core.http_enum` | -| Domain / IP intelligence | WHOIS, dig | `osint.domain`, `osint.ip` | -| Email intelligence | HIBP, emailrep | `osint.email` | -| Phone intelligence | NumVerify | `osint.phone` | -| SMB / RDP / LDAP enumeration | enum4linux, CrackMapExec | `protocols.*` | -| Link / subdomain discovery | LinkFinder, Sublist3r | `osint.link_sniffer` | -| CVE correlation | Vulners, SearchSploit | `attack.vuln_scanner` | -| Web directory enumeration | Dirb, Gobuster | `attack.web_enumerator` | -| Onion reconnaissance | Manual Tor tooling | `darkweb.*` | -| Bitcoin address profiling | Blockchain APIs | `darkweb.btc_first_seen` | - -Everything is presented through a **Rich-powered CLI**, with persistent scan -history and JSON / CSV / HTML reporting. The project aims to keep protocol -logic transparent and modular rather than hiding everything behind a single -black-box dependency. - ---- - -## ✨ Features - -### 🛠️ Core Scanning Engine - -High-speed network enumeration and service identification. - -- 🔍 **Multi-protocol scanning** - - TCP connect - - SYN / raw-socket scanning - - UDP probing - - Configurable timing profiles from `T0` to `T5` -- 🛡️ **Firewall / IDS heuristics** - - RST and ICMP behavior analysis - - Open / filtered / dropped classification -- 🕵️ **OS fingerprinting** - - TTL - - TCP window size - - DF-bit heuristics - - Linux / Windows / BSD / network-device families -- 🔐 **SSL/TLS analysis** - - Certificate chain inspection - - Cipher-suite enumeration - - Protocol-version checks - - Expiration warnings -- 🌐 **HTTP enumeration** - - `Server` - - `X-Powered-By` - - Allowed methods - - Common-path discovery such as `/admin`, `/login`, and `/.git` -- 📡 **Network discovery** - - ICMP sweeps - - ARP table walking - - Traceroute-related helpers -- ⚡ **Threaded execution** - - Dynamic `ThreadPoolExecutor` sizing - - Default upper bound of `min(512, ports × targets)` - ---- - -### 🕵️ OSINT Intelligence Suite - -Designed for gathering publicly available intelligence around domains, -IP addresses, emails, phones, images, and links. - -- 🌐 **Domain intelligence** - - WHOIS - - A / AAAA / MX / NS / TXT / SOA / CNAME - - Host reachability -- 📍 **IP intelligence** - - GeoIP - - ASN - - WHOIS - - Optional Shodan enrichment -- 📞 **Phone intelligence** - - Carrier - - Line type - - Country - - Geo lookup via NumVerify -- 📧 **Email intelligence** - - Reputation - - Breach flags - - Disposable-mail detection - - Free-provider classification -- 🖼️ **Metadata extraction** - - EXIF / IPTC / XMP - - JPEG / PNG / TIFF - - `exiv2` / `exiftool` fallback -- 🔗 **Link sniffing** - - HackerTarget API - - Optional depth-2 on-page spider -- 👮 **Public-record search helper** - - Generates U.S. state-specific public-record search links - - Does not query private or restricted databases - ---- - -### ⚔️ Attack & Vulnerability Modules - -> **Active testing requires explicit authorization. Only use these modules against -> systems you own or are explicitly permitted to test.** - -- 💥 **Credential testing** - - Dictionary-based SSH / FTP testing - - Rate limiting - - Jitter - - Lockout-aware back-off -- 🛡️ **CVE scanner** - - Matches detected service banners against the NVD CVE 2.0 API - - Supports local CVE caching -- 📂 **Web enumeration** - - HTTP / HTTPS directory and file discovery - - Custom wordlists - - Recursive scanning - - Status-code filtering - ---- - -### 🔬 Protocol Enumeration - -Protocol-aware service inspection implemented primarily in Python. - -| Protocol | Module | Detection / Heuristics | -|---|---|---| -| SMB/CIFS | `smb_enum` | SMBv1/v2/v3 negotiation, shares, anonymous auth, OS fingerprinting, EternalBlue heuristic | -| SNMP | `snmp_enum` | BER encoding, community checks, system information, `snmpwalk` fallback | -| LDAP/LDAPS | `ldap_enum` | Anonymous bind, Root DSE, user/group enumeration | -| RDP | `rdp_enum` | X.224 / TPKT handshake, NLA, BlueKeep heuristic | -| SMTP | `smtp_enum` | Banner, VRFY/EXPN, relay test, STARTTLS | -| DNS | `dns_zone` | AXFR attempts against discovered NS records | -| NFS | `nfs_enum` | RPC / MOUNTD inspection, export checks | -| VNC | `vnc_enum` | RFB handshake, authentication types, no-auth detection | -| Redis | `redis_enum` | RESP inspection, INFO / DBSIZE / RANDOMKEY sampling | -| MongoDB | `mongodb_enum` | OP_MSG, BSON parsing, unauthenticated-access detection | -| SIP | `sip_enum` | UDP OPTIONS probing | -| RTSP | `rtsp_enum` | DESCRIBE requests, SDP capture, auth checks | -| Databases | `database_enum` | MySQL / PostgreSQL / MSSQL version detection | - ---- - -## 🌑 Dark Web Recon - -SpectraScan includes a dedicated **passive reconnaissance** module for `.onion` -services and publicly observable dark-web references. - -**No marketplace interaction. No payload execution. No automatic Tor launch.** - -### 🔎 Automatic Target Detection - -The module identifies likely target types before analysis: - -| Type | Detection | Confidence | -|---|---|---:| -| `.onion` v3 | 56-character Base32 pattern | 100% | -| `.onion` v2 | 16-character Base32 pattern | 100% | -| BTC address | Base58Check / Bech32 / Bech32m | 100% | -| ETH address | EIP-55 pattern | 95% | -| XMR / LTC | Format heuristic | 75% | -| Email | RFC-5322-lite pattern | 95% | -| IPv4 | Python `ipaddress` | 95% | -| Hash | MD5 / SHA-1 / SHA-256 | 90–95% | -| PGP block | ASCII-armored header | 100% | -| Phone | International format heuristic | 70% | -| Domain | RFC-1035-lite pattern | 80% | -| Username | Heuristic | 40% | - -### 🧅 Onion Recon - -For supported `.onion` targets: - -- HTTPS / HTTP banner collection through Tor SOCKS5 -- TLS version and cipher suite -- Certificate subject / issuer / SAN -- SHA-1 and SHA-256 certificate fingerprints -- Certificate expiration -- HTTP status and selected headers -- HTML `` extraction -- Custom port and scheme -- Hard 30-second network timeout - -### 💰 Bitcoin Address Profiling - -The module can query public blockchain APIs using a fallback chain: - -1. Blockchair -2. Blockstream -3. Blockchain.info - -Reported information can include: - -- First-seen timestamp -- First-seen block -- Address age -- Total received -- Total sent -- Current balance -- Transaction count -- Heuristic risk indicators - -### 🔍 Other Utilities - -- 🔗 Extract `.onion` links from arbitrary text -- 🌐 Search Ahmia through clearnet -- 🔌 Check local Tor SOCKS5 connectivity -- 🧭 Verify the Tor exit connection through Tor Project's check endpoint - -### ⚖️ Guardrails - -- ✅ Network timeouts capped at 30 seconds -- ✅ Clearnet-first workflows where possible -- ✅ No binary downloads -- ✅ No marketplace interaction -- ✅ No payload execution -- ✅ No automatic Tor startup -- ✅ No login / authentication attempts against dark-web services - ---- - -## 📁 Report Management - -SpectraScan keeps scan output organized and reusable. - -- 📊 Export reports as **JSON, CSV, or HTML** -- 💾 Store reports under: - `~/.local/share/SpectraScan/` -- 📂 List, inspect, delete, and re-export historical scans -- ⏱️ Attach UTC timestamps and UUIDs to scan runs - ---- - -## 🏗️ Architecture - -```text - ┌───────────────────────────────┐ - │ SpectraScan.py │ - │ CLI + Rich + Dispatcher │ - └───────────────┬───────────────┘ - │ - ┌──────────────────────────┼──────────────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌──────────────┐ ┌────────────────┐ ┌────────────────┐ - │ Core Engine │ │ OSINT Suite │ │ Attack Modules │ - └──────┬───────┘ └───────┬────────┘ └───────┬────────┘ - │ │ │ - └──────────────────────────┼──────────────────────────┘ - │ - ▼ - ┌────────────────────┐ - │ Protocol Modules │ - │ SMB / SNMP / LDAP │ - │ RDP / SMTP / DNS │ - │ NFS / VNC / DB ... │ - └─────────┬──────────┘ - │ - ┌──────────────┴──────────────┐ - ▼ ▼ - ┌─────────────────┐ ┌──────────────────┐ - │ Third-party APIs│ │ Dark Web Recon │ - │ WHOIS / Shodan │ │ Tor / Ahmia │ - │ NVD / emailrep │ └────────┬─────────┘ - └─────────────────┘ │ - ▼ - ┌────────────────┐ - │ Bitcoin APIs │ - └────────────────┘ -``` - -### Design Principles - -1. **Modular** — each capability lives in a focused module. -2. **Pure Python first** — protocol functionality avoids unnecessary external - binaries where practical. -3. **Fail-soft** — external failures should degrade gracefully. -4. **Read-only by default** — active capabilities are explicit opt-in features. -5. **Traceable output** — scans produce timestamped, reusable reports. - ---- - -## 📦 Installation - -### Prerequisites - -| Requirement | Purpose | Required? | -|---|---|---| -| Python 3.9+ | Core runtime | ✅ | -| Root / Administrator | Raw sockets for SYN scans | Only for SYN mode | -| Tor on `127.0.0.1:9050` | `.onion` operations | Only for onion features | -| `exiv2` or `exiftool` | Image metadata | Optional | -| `shodan-cli` | Shodan enrichment | Optional | -| `nmap` | Banner/version correlation | Optional | - -### 1. Clone - -```bash -git clone https://github.com/ItsWanheda/SpectraScan.git -cd SpectraScan -``` - -### 2. Create a virtual environment - -```bash -python -m venv venv -``` - -**Linux / macOS** - -```bash -source venv/bin/activate -``` - -**Windows PowerShell** - -```powershell -.\venv\Scripts\Activate.ps1 -``` - -### 3. Install dependencies - -```bash -pip install -r requirements.txt -``` - -### 4. Optional dependencies - -```bash -pip install pysocks cryptography -``` - -### 5. Optional system tools - -**Debian / Ubuntu** - -```bash -sudo apt install exiftool tor nmap -``` - -**macOS** - -```bash -brew install exiftool tor nmap -``` - -**Windows** - -```powershell -choco install exiftool tor nmap -``` - -### 🧅 Tor Setup - -```bash -sudo apt install tor -sudo systemctl start tor -sudo systemctl status tor -``` - -For Tor Browser, use port `9150` instead of `9050`: - -```bash -export TOR_PORT=9150 -``` - ---- - -## ⚡ Quick Start - -### Interactive CLI - -```bash -pip install rich -python SpectraScan.py -``` - -### One-shot scan - -```bash -python SpectraScan.py -t scanme.nmap.org -p 1-1000 -T T3 -``` - -> Only scan hosts you own or have explicit permission to test. - ---- - -## 🚀 Usage - -### 🔍 Basic Reconnaissance - -```bash -# Standard TCP scan -python SpectraScan.py -t 192.168.1.1 - -# OS detection + all ports -python SpectraScan.py -t example.com --os-detect -p- -T T4 - -# UDP probes -python SpectraScan.py -t 10.0.0.5 --scan-type udp -p 53,161,514 -``` - -### 🕵️ OSINT - -```bash -# Email reputation -python SpectraScan.py -e target@example.com - -# Domain intelligence + link sniffing -python SpectraScan.py -d targetdomain.com -l - -# IP intelligence + Shodan -python SpectraScan.py --ip 8.8.8.8 --shodan - -# Phone lookup -python SpectraScan.py --phone "+14155552671" - -# Image metadata -python SpectraScan.py --image ./photo.jpg -``` - -### ⚔️ Advanced Modules - -```bash -# CVE correlation -python SpectraScan.py -t target.com --vuln-scan - -# SSH / FTP credential testing -python SpectraScan.py -t 10.0.0.5 --brute-force --wordlist ./passwords.txt - -# Web directory enumeration -python SpectraScan.py -t example.com --web-enum --wordlist ./dirb_list.txt - -# SMB enumeration -python SpectraScan.py -t 10.0.0.5 --smb - -# DNS zone-transfer check -python SpectraScan.py -d target.com --dns-zone - -# LDAP anonymous-bind check -python SpectraScan.py -t 10.0.0.5 --ldap -``` - -### 🌑 Dark Web Recon - -Dark-web functionality is exposed through the interactive menu: - -```text -Main Menu -└── 3. Protocol Modules - └── 14. Dark Web Recon -``` - -Available operations: - -| Option | Action | Tor | -|---:|---|:---:| -| `1` | Auto-detect & analyze target | Depends | -| `2` | HTTPS `.onion` banner grab | ✅ | -| `3` | BTC first-seen + heuristic analysis | ❌ | -| `4` | Extract `.onion` links from text | ❌ | -| `5` | Ahmia search | ❌ | -| `6` | Tor connectivity check | ✅ | -| `7` | Full recon | Depends | -| `8` | Back | — | - -### 📊 Reports - -```bash -# View saved reports -python SpectraScan.py -r - -# HTML report -python SpectraScan.py -t target.com -o report.html -f html - -# JSON report -python SpectraScan.py -t target.com -o report.json -f json -``` - ---- - -## 🧭 CLI Menu - -```text -╭──────────────────────────────────────────────╮ -│ SpectraScan │ -├──────────────────────────────────────────────┤ -│ 1. Port Scanner │ -│ 2. Advanced Modules │ -│ 3. Protocol Modules │ -│ 4. Exit │ -╰──────────────────────────────────────────────╯ -``` - -### Menu Overview - -| Menu | Includes | -|---|---| -| **1. Port Scanner** | TCP / SYN / UDP · timing profiles · port ranges / lists / top-N | -| **2. Advanced Modules** | Domain · IP · Email · Phone · Image EXIF · Links · Reports | -| **3. Protocol Modules** | SMB · SNMP · LDAP · RDP · SMTP · DNS · NFS · VNC · Redis · MongoDB · SIP · RTSP · Databases · Dark Web | -| **4. Exit** | Clean shutdown and report flushing | - ---- - -## ⚙️ Configuration - -### Timing Profiles - -The profiles follow an Nmap-style naming convention: - -| Profile | Name | Description | -|---|---|---| -| `T0` | Paranoid | Extremely slow | -| `T1` | Sneaky | Low-noise scanning | -| `T2` | Polite | Conservative bandwidth usage | -| `T3` | Normal | Default balanced profile | -| `T4` | Aggressive | Faster, noisier | -| `T5` | Insane | Maximum speed and noise | - -### Output Verbosity - -```text --v INFO Open ports and normal status --vv DEBUG Detailed probes and raw banners --q QUIET Final report only -``` - ---- - -## 📊 Output Examples - -### Port Scan - -```text -PORT STATE SERVICE VERSION -22/tcp open ssh OpenSSH 8.9p1 -80/tcp open http nginx 1.24.0 -443/tcp open https nginx / TLS 1.3 -``` - -### Saved Reports - -```text -~/.local/share/SpectraScan/ -├── SS-portscan-2026-06-27-11-13-44.json -├── SS-darkweb-btc-2026-06-27-11-15-22.json -├── SS-osint-domain-2026-06-27-11-20-01.html -└── SS-protocol-smb-2026-06-27-11-25-09.csv -``` - ---- - -## 🧩 Project Structure - -```text -SpectraScan/ -├── SpectraScan.py -├── modules/ -│ ├── __init__.py -│ ├── brute_forcer.py -│ ├── vuln_scanner.py -│ ├── web_enumerator.py -│ ├── phone_scanner.py -│ ├── smb_enum.py -│ ├── snmp_enum.py -│ ├── ldap_enum.py -│ ├── rdp_enum.py -│ ├── smtp_enum.py -│ ├── dns_zone.py -│ ├── nfs_enum.py -│ ├── vnc_enum.py -│ ├── redis_enum.py -│ ├── mongodb_enum.py -│ ├── sip_enum.py -│ ├── rtsp_enum.py -│ ├── database_enum.py -│ ├── network_services.py -│ └── darkweb.py -├── CHANGELOG.md -├── LICENSE -├── README.md -├── requirements.txt -└── .gitignore -``` - ---- - -## ⚡ Performance & Threading - -| Component | Strategy | Default | -|---|---|---:| -| Port scanner | `ThreadPoolExecutor` | `min(512, ports × targets)` | -| Protocol enumeration | One thread per protocol | `len(protocols)` | -| Brute forcer | Thread pool + semaphore | 16 | -| Web fuzzer | `ThreadPoolExecutor` | 32 | -| DNS zone | Serial | 1 | -| BTC lookup | Serial fallback chain | 1 | -| Dark-web banner | Serial / Tor-bound | 1 | - -### Network Fairness - -- Modules can apply jitter between bursts. -- Credential testing uses exponential back-off after repeated failures. -- `--throttle N` provides a global millisecond delay override. - ---- - -## 🌍 Environment Variables - -| Variable | Default | Purpose | -|---|---|---| -| `TOR_HOST` | `127.0.0.1` | Tor SOCKS5 host | -| `TOR_PORT` | `9050` | Tor SOCKS5 port | -| `SPECTRASCAN_HOME` | `~/.local/share/SpectraScan` | Report directory | -| `NUMVERIFY_KEY` | — | NumVerify API key | -| `SHODAN_KEY` | — | Shodan API key | -| `NVD_API_KEY` | — | NVD API key | -| `EMAILREP_KEY` | — | emailrep.io key | -| `HTTP_PROXY` | — | Global outbound HTTP proxy | - ---- - -## 🆚 Comparison - -SpectraScan is intended to complement established security tooling rather than -replace every specialized tool. - -| Capability | SpectraScan | Nmap | Recon-ng | SpiderFoot | -|---|:---:|:---:|:---:|:---:| -| Port scanning | ✅ | ✅ | ⚠️ | ❌ | -| OS fingerprinting | ✅ | ✅ | ⚠️ | ⚠️ | -| OSINT suite | ✅ | ❌ | ✅ | ✅ | -| SMB / LDAP / SNMP | ✅ | ⚠️ | ⚠️ | ❌ | -| Credential testing | ✅ | ❌ | ⚠️ | ❌ | -| CVE correlation | ✅ | ⚠️ | ⚠️ | ✅ | -| Dark-web recon | ✅ | ❌ | ⚠️ | ✅ | -| BTC profiling | ✅ | ❌ | ❌ | ⚠️ | -| HTML reports | ✅ | ⚠️ | ✅ | ✅ | -| Interactive CLI | ✅ | ❌ | ✅ | ❌ | - ---- - -## 🩺 Troubleshooting - -| Problem | Likely Cause | Fix | -|---|---|---| -| `Permission denied` on SYN scan | Missing privileges | Run with appropriate administrator/root privileges | -| `.onion` connection hangs | Tor unavailable / wrong port | Check `TOR_HOST` and `TOR_PORT` | -| `pysocks` missing | Optional dependency absent | `pip install pysocks` | -| TLS parsing is limited | `cryptography` missing | `pip install cryptography` | -| BTC APIs fail | Network / rate limit | Retry later or review proxy settings | -| Rich UI looks broken | Terminal capability issue | Use a 256-color capable terminal | -| `ModuleNotFoundError: requests` | Dependency missing | Install project requirements | -| Windows SYN scan fails | Raw-packet support unavailable | Install a compatible Npcap setup | -| Reports use the wrong folder | Custom path not configured | Set `SPECTRASCAN_HOME` | - ---- - +<div align="center"> + +# 🕵️‍♂️ SpectraScan + +### Advanced Network Reconnaissance, Port Scanning & OSINT Intelligence Framework + +<p> + <a href="https://github.com/ItsWanheda/SpectraScan/blob/main/LICENSE"> + <img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License"> + </a> + <img src="https://img.shields.io/badge/Python-3.9%2B-blue.svg" alt="Python"> + <img src="https://img.shields.io/badge/Platform-Linux%20%7C%20macOS%20%7C%20Windows-lightgrey.svg" alt="Platform"> + <img src="https://img.shields.io/badge/Status-Stable-brightgreen.svg" alt="Status"> + <img src="https://img.shields.io/badge/Code%20Style-Black-000000.svg" alt="Black"> + <img src="https://img.shields.io/badge/PRs-Welcome-brightgreen.svg" alt="PRs Welcome"> +</p> + +<p> + <strong>One modular CLI for network scanning, service enumeration, OSINT, + vulnerability correlation, protocol intelligence, and passive dark-web reconnaissance.</strong> +</p> + +<p> + <a href="#-why-spectrascan">Why SpectraScan</a> · + <a href="#-features">Features</a> · + <a href="#-installation">Installation</a> · + <a href="#-quick-start">Quick Start</a> · + <a href="#-architecture">Architecture</a> · + <a href="#-roadmap">Roadmap</a> +</p> + +</div> + +--- + +## 📑 Table of Contents + +- [🎯 Why SpectraScan?](#-why-spectrascan) +- [✨ Features](#-features) + - [Core Scanning Engine](#-core-scanning-engine) + - [OSINT Intelligence Suite](#-osint-intelligence-suite) + - [Attack & Vulnerability Modules](#-attack--vulnerability-modules) + - [Protocol Enumeration](#-protocol-enumeration) + - [Dark Web Recon](#-dark-web-recon) + - [Report Management](#-report-management) +- [🏗️ Architecture](#-architecture) +- [📦 Installation](#-installation) +- [⚡ Quick Start](#-quick-start) +- [🚀 Usage](#-usage) +- [🧭 CLI Menu](#-cli-menu) +- [⚙️ Configuration](#-configuration) +- [📊 Output Examples](#-output-examples) +- [🧩 Project Structure](#-project-structure) +- [⚡ Performance & Threading](#-performance--threading) +- [🌍 Environment Variables](#-environment-variables) +- [🆚 Comparison](#-comparison) +- [🩺 Troubleshooting](#-troubleshooting) +- [🛣️ Roadmap](#-roadmap) +- [🛡️ Security Notes](#-security-notes) +- [🤝 Contributing](#-contributing) +- [🙏 Acknowledgements](#-acknowledgements) +- [⚠️ Disclaimer](#-disclaimer) +- [📜 License](#-license) + +--- + +## 🎯 Why SpectraScan? + +Reconnaissance usually means switching between several tools, formats, terminals, +and output styles. **SpectraScan** brings a broad collection of reconnaissance +and enumeration workflows into one modular Python CLI. + +| Need | Typical Tooling | SpectraScan | +|---|---|---| +| Fast port scanning | Nmap, Masscan | `core.scanner` | +| Web fingerprinting | WhatWeb, Nikto | `core.http_enum` | +| Domain / IP intelligence | WHOIS, dig | `osint.domain`, `osint.ip` | +| Email intelligence | HIBP, emailrep | `osint.email` | +| Phone intelligence | NumVerify | `osint.phone` | +| SMB / RDP / LDAP enumeration | enum4linux, CrackMapExec | `protocols.*` | +| Link / subdomain discovery | LinkFinder, Sublist3r | `osint.link_sniffer` | +| CVE correlation | Vulners, SearchSploit | `attack.vuln_scanner` | +| Web directory enumeration | Dirb, Gobuster | `attack.web_enumerator` | +| Onion reconnaissance | Manual Tor tooling | `darkweb.*` | +| Bitcoin address profiling | Blockchain APIs | `darkweb.btc_first_seen` | + +Everything is presented through a **Rich-powered CLI**, with persistent scan +history and JSON / CSV / HTML reporting. The project aims to keep protocol +logic transparent and modular rather than hiding everything behind a single +black-box dependency. + +--- + +## ✨ Features + +### 🛠️ Core Scanning Engine + +High-speed network enumeration and service identification. + +- 🔍 **Multi-protocol scanning** + - TCP connect + - SYN / raw-socket scanning + - UDP probing + - Configurable timing profiles from `T0` to `T5` +- 🛡️ **Firewall / IDS heuristics** + - RST and ICMP behavior analysis + - Open / filtered / dropped classification +- 🕵️ **OS fingerprinting** + - TTL + - TCP window size + - DF-bit heuristics + - Linux / Windows / BSD / network-device families +- 🔐 **SSL/TLS analysis** + - Certificate chain inspection + - Cipher-suite enumeration + - Protocol-version checks + - Expiration warnings +- 🌐 **HTTP enumeration** + - `Server` + - `X-Powered-By` + - Allowed methods + - Common-path discovery such as `/admin`, `/login`, and `/.git` +- 📡 **Network discovery** + - ICMP sweeps + - ARP table walking + - Traceroute-related helpers +- ⚡ **Threaded execution** + - Dynamic `ThreadPoolExecutor` sizing + - Default upper bound of `min(512, ports × targets)` + +--- + +### 🕵️ OSINT Intelligence Suite + +Designed for gathering publicly available intelligence around domains, +IP addresses, emails, phones, images, and links. + +- 🌐 **Domain intelligence** + - WHOIS + - A / AAAA / MX / NS / TXT / SOA / CNAME + - Host reachability +- 📍 **IP intelligence** + - GeoIP + - ASN + - WHOIS + - Optional Shodan enrichment +- 📞 **Phone intelligence** + - Carrier + - Line type + - Country + - Geo lookup via NumVerify +- 📧 **Email intelligence** + - Reputation + - Breach flags + - Disposable-mail detection + - Free-provider classification +- 🖼️ **Metadata extraction** + - EXIF / IPTC / XMP + - JPEG / PNG / TIFF + - `exiv2` / `exiftool` fallback +- 🔗 **Link sniffing** + - HackerTarget API + - Optional depth-2 on-page spider +- 👮 **Public-record search helper** + - Generates U.S. state-specific public-record search links + - Does not query private or restricted databases + +--- + +### ⚔️ Attack & Vulnerability Modules + +> **Active testing requires explicit authorization. Only use these modules against +> systems you own or are explicitly permitted to test.** + +- 💥 **Credential testing** + - Dictionary-based SSH / FTP testing + - Rate limiting + - Jitter + - Lockout-aware back-off +- 🛡️ **CVE scanner** + - Matches detected service banners against the NVD CVE 2.0 API + - Supports local CVE caching +- 📂 **Web enumeration** + - HTTP / HTTPS directory and file discovery + - Custom wordlists + - Recursive scanning + - Status-code filtering + +--- + +### 🔬 Protocol Enumeration + +Protocol-aware service inspection implemented primarily in Python. + +| Protocol | Module | Detection / Heuristics | +|---|---|---| +| SMB/CIFS | `smb_enum` | SMBv1/v2/v3 negotiation, shares, anonymous auth, OS fingerprinting, EternalBlue heuristic | +| SNMP | `snmp_enum` | BER encoding, community checks, system information, `snmpwalk` fallback | +| LDAP/LDAPS | `ldap_enum` | Anonymous bind, Root DSE, user/group enumeration | +| RDP | `rdp_enum` | X.224 / TPKT handshake, NLA, BlueKeep heuristic | +| SMTP | `smtp_enum` | Banner, VRFY/EXPN, relay test, STARTTLS | +| DNS | `dns_zone` | AXFR attempts against discovered NS records | +| NFS | `nfs_enum` | RPC / MOUNTD inspection, export checks | +| VNC | `vnc_enum` | RFB handshake, authentication types, no-auth detection | +| Redis | `redis_enum` | RESP inspection, INFO / DBSIZE / RANDOMKEY sampling | +| MongoDB | `mongodb_enum` | OP_MSG, BSON parsing, unauthenticated-access detection | +| SIP | `sip_enum` | UDP OPTIONS probing | +| RTSP | `rtsp_enum` | DESCRIBE requests, SDP capture, auth checks | +| Databases | `database_enum` | MySQL / PostgreSQL / MSSQL version detection | + +--- + +## 🌑 Dark Web Recon + +SpectraScan includes a dedicated **passive reconnaissance** module for `.onion` +services and publicly observable dark-web references. + +**No marketplace interaction. No payload execution. No automatic Tor launch.** + +### 🔎 Automatic Target Detection + +The module identifies likely target types before analysis: + +| Type | Detection | Confidence | +|---|---|---:| +| `.onion` v3 | 56-character Base32 pattern | 100% | +| `.onion` v2 | 16-character Base32 pattern | 100% | +| BTC address | Base58Check / Bech32 / Bech32m | 100% | +| ETH address | EIP-55 pattern | 95% | +| XMR / LTC | Format heuristic | 75% | +| Email | RFC-5322-lite pattern | 95% | +| IPv4 | Python `ipaddress` | 95% | +| Hash | MD5 / SHA-1 / SHA-256 | 90–95% | +| PGP block | ASCII-armored header | 100% | +| Phone | International format heuristic | 70% | +| Domain | RFC-1035-lite pattern | 80% | +| Username | Heuristic | 40% | + +### 🧅 Onion Recon + +For supported `.onion` targets: + +- HTTPS / HTTP banner collection through Tor SOCKS5 +- TLS version and cipher suite +- Certificate subject / issuer / SAN +- SHA-1 and SHA-256 certificate fingerprints +- Certificate expiration +- HTTP status and selected headers +- HTML `<title>` extraction +- Custom port and scheme +- Hard 30-second network timeout + +### 💰 Bitcoin Address Profiling + +The module can query public blockchain APIs using a fallback chain: + +1. Blockchair +2. Blockstream +3. Blockchain.info + +Reported information can include: + +- First-seen timestamp +- First-seen block +- Address age +- Total received +- Total sent +- Current balance +- Transaction count +- Heuristic risk indicators + +### 🔍 Other Utilities + +- 🔗 Extract `.onion` links from arbitrary text +- 🌐 Search Ahmia through clearnet +- 🔌 Check local Tor SOCKS5 connectivity +- 🧭 Verify the Tor exit connection through Tor Project's check endpoint + +### ⚖️ Guardrails + +- ✅ Network timeouts capped at 30 seconds +- ✅ Clearnet-first workflows where possible +- ✅ No binary downloads +- ✅ No marketplace interaction +- ✅ No payload execution +- ✅ No automatic Tor startup +- ✅ No login / authentication attempts against dark-web services + +--- + +## 📁 Report Management + +SpectraScan keeps scan output organized and reusable. + +- 📊 Export reports as **JSON, CSV, or HTML** +- 💾 Store reports under: + `~/.local/share/SpectraScan/` +- 📂 List, inspect, delete, and re-export historical scans +- ⏱️ Attach UTC timestamps and UUIDs to scan runs + +--- + +## 🏗️ Architecture + +```text + ┌───────────────────────────────┐ + │ SpectraScan.py │ + │ CLI + Rich + Dispatcher │ + └───────────────┬───────────────┘ + │ + ┌──────────────────────────┼──────────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌──────────────┐ ┌────────────────┐ ┌────────────────┐ + │ Core Engine │ │ OSINT Suite │ │ Attack Modules │ + └──────┬───────┘ └───────┬────────┘ └───────┬────────┘ + │ │ │ + └──────────────────────────┼──────────────────────────┘ + │ + ▼ + ┌────────────────────┐ + │ Protocol Modules │ + │ SMB / SNMP / LDAP │ + │ RDP / SMTP / DNS │ + │ NFS / VNC / DB ... │ + └─────────┬──────────┘ + │ + ┌──────────────┴──────────────┐ + ▼ ▼ + ┌─────────────────┐ ┌──────────────────┐ + │ Third-party APIs│ │ Dark Web Recon │ + │ WHOIS / Shodan │ │ Tor / Ahmia │ + │ NVD / emailrep │ └────────┬─────────┘ + └─────────────────┘ │ + ▼ + ┌────────────────┐ + │ Bitcoin APIs │ + └────────────────┘ +``` + +### Design Principles + +1. **Modular** — each capability lives in a focused module. +2. **Pure Python first** — protocol functionality avoids unnecessary external + binaries where practical. +3. **Fail-soft** — external failures should degrade gracefully. +4. **Read-only by default** — active capabilities are explicit opt-in features. +5. **Traceable output** — scans produce timestamped, reusable reports. + +--- + +## 📦 Installation + +### Prerequisites + +| Requirement | Purpose | Required? | +|---|---|---| +| Python 3.10+ | Core runtime | ✅ | +| Root / Administrator | Raw sockets for SYN scans | Only for SYN mode | +| Tor on `127.0.0.1:9050` | `.onion` operations | Only for onion features | +| `exiv2` or `exiftool` | Image metadata | Optional | +| `shodan-cli` | Shodan enrichment | Optional | +| `nmap` | Banner/version correlation | Optional | + +### 1. Clone + +```bash +git clone https://github.com/ItsWanheda/SpectraScan.git +cd SpectraScan +``` + +### 2. Create a virtual environment + +```bash +python -m venv venv +``` + +**Linux / macOS** + +```bash +source venv/bin/activate +``` + +**Windows PowerShell** + +```powershell +.\venv\Scripts\Activate.ps1 +``` + +### 3. Install dependencies + +```bash +pip install -r requirements.txt +``` + +### 4. Optional dependencies + +```bash +pip install pysocks cryptography +``` + +### 5. Optional system tools + +**Debian / Ubuntu** + +```bash +sudo apt install exiftool tor nmap +``` + +**macOS** + +```bash +brew install exiftool tor nmap +``` + +**Windows** + +```powershell +choco install exiftool tor nmap +``` + +### 🧅 Tor Setup + +```bash +sudo apt install tor +sudo systemctl start tor +sudo systemctl status tor +``` + +For Tor Browser, use port `9150` instead of `9050`: + +```bash +export TOR_PORT=9150 +``` + +--- + +## ⚡ Quick Start + +### Interactive CLI + +```bash +pip install rich +python SpectraScan.py +``` + +### One-shot scan + +```bash +python SpectraScan.py -t scanme.nmap.org -p 1-1000 -T T3 +``` + +> Only scan hosts you own or have explicit permission to test. + +--- + +## 🚀 Usage + +### 🔍 Basic Reconnaissance + +```bash +# Standard TCP scan +python SpectraScan.py -t 192.168.1.1 + +# OS detection + all ports +python SpectraScan.py -t example.com --os-detect -p- -T T4 + +# UDP probes +python SpectraScan.py -t 10.0.0.5 --scan-type udp -p 53,161,514 +``` + +### 🕵️ OSINT + +```bash +# Email reputation +python SpectraScan.py -e target@example.com + +# Domain intelligence + link sniffing +python SpectraScan.py -d targetdomain.com -l + +# IP intelligence + Shodan +python SpectraScan.py --ip 8.8.8.8 --shodan + +# Phone lookup +python SpectraScan.py --phone "+14155552671" + +# Image metadata +python SpectraScan.py --image ./photo.jpg +``` + +### ⚔️ Advanced Modules + +```bash +# CVE correlation +python SpectraScan.py -t target.com --vuln-scan + +# SSH / FTP credential testing +python SpectraScan.py -t 10.0.0.5 --brute-force --wordlist ./passwords.txt + +# Web directory enumeration +python SpectraScan.py -t example.com --web-enum --wordlist ./dirb_list.txt + +# SMB enumeration +python SpectraScan.py -t 10.0.0.5 --smb + +# DNS zone-transfer check +python SpectraScan.py -d target.com --dns-zone + +# LDAP anonymous-bind check +python SpectraScan.py -t 10.0.0.5 --ldap +``` + +### 🌑 Dark Web Recon + +Dark-web functionality is exposed through the interactive menu: + +```text +Main Menu +└── 3. Protocol Modules + └── 14. Dark Web Recon +``` + +Available operations: + +| Option | Action | Tor | +|---:|---|:---:| +| `1` | Auto-detect & analyze target | Depends | +| `2` | HTTPS `.onion` banner grab | ✅ | +| `3` | BTC first-seen + heuristic analysis | ❌ | +| `4` | Extract `.onion` links from text | ❌ | +| `5` | Ahmia search | ❌ | +| `6` | Tor connectivity check | ✅ | +| `7` | Full recon | Depends | +| `8` | Back | — | + +### 📊 Reports + +```bash +# View saved reports +python SpectraScan.py -r + +# HTML report +python SpectraScan.py -t target.com -o report.html -f html + +# JSON report +python SpectraScan.py -t target.com -o report.json -f json +``` + +--- + +## 🧭 CLI Menu + +```text +╭──────────────────────────────────────────────╮ +│ SpectraScan │ +├──────────────────────────────────────────────┤ +│ 1. Port Scanner │ +│ 2. Advanced Modules │ +│ 3. Protocol Modules │ +│ 4. Exit │ +╰──────────────────────────────────────────────╯ +``` + +### Menu Overview + +| Menu | Includes | +|---|---| +| **1. Port Scanner** | TCP / SYN / UDP · timing profiles · port ranges / lists / top-N | +| **2. Advanced Modules** | Domain · IP · Email · Phone · Image EXIF · Links · Reports | +| **3. Protocol Modules** | SMB · SNMP · LDAP · RDP · SMTP · DNS · NFS · VNC · Redis · MongoDB · SIP · RTSP · Databases · Dark Web | +| **4. Exit** | Clean shutdown and report flushing | + +--- + +## ⚙️ Configuration + +### Timing Profiles + +The profiles follow an Nmap-style naming convention: + +| Profile | Name | Description | +|---|---|---| +| `T0` | Paranoid | Extremely slow | +| `T1` | Sneaky | Low-noise scanning | +| `T2` | Polite | Conservative bandwidth usage | +| `T3` | Normal | Default balanced profile | +| `T4` | Aggressive | Faster, noisier | +| `T5` | Insane | Maximum speed and noise | + +### Output Verbosity + +```text +-v INFO Open ports and normal status +-vv DEBUG Detailed probes and raw banners +-q QUIET Final report only +``` + +--- + +## 📊 Output Examples + +### Port Scan + +```text +PORT STATE SERVICE VERSION +22/tcp open ssh OpenSSH 8.9p1 +80/tcp open http nginx 1.24.0 +443/tcp open https nginx / TLS 1.3 +``` + +### Saved Reports + +```text +~/.local/share/SpectraScan/ +├── SS-portscan-2026-06-27-11-13-44.json +├── SS-darkweb-btc-2026-06-27-11-15-22.json +├── SS-osint-domain-2026-06-27-11-20-01.html +└── SS-protocol-smb-2026-06-27-11-25-09.csv +``` + +--- + +## 🧩 Project Structure + +```text +SpectraScan/ +├── SpectraScan.py +├── modules/ +│ ├── __init__.py +│ ├── brute_forcer.py +│ ├── vuln_scanner.py +│ ├── web_enumerator.py +│ ├── phone_scanner.py +│ ├── smb_enum.py +│ ├── snmp_enum.py +│ ├── ldap_enum.py +│ ├── rdp_enum.py +│ ├── smtp_enum.py +│ ├── dns_zone.py +│ ├── nfs_enum.py +│ ├── vnc_enum.py +│ ├── redis_enum.py +│ ├── mongodb_enum.py +│ ├── sip_enum.py +│ ├── rtsp_enum.py +│ ├── database_enum.py +│ ├── network_services.py +│ └── darkweb.py +├── CHANGELOG.md +├── LICENSE +├── README.md +├── requirements.txt +└── .gitignore +``` + +--- + +## ⚡ Performance & Threading + +| Component | Strategy | Default | +|---|---|---:| +| Port scanner | `ThreadPoolExecutor` | `min(512, ports × targets)` | +| Protocol enumeration | One thread per protocol | `len(protocols)` | +| Brute forcer | Thread pool + semaphore | 16 | +| Web fuzzer | `ThreadPoolExecutor` | 32 | +| DNS zone | Serial | 1 | +| BTC lookup | Serial fallback chain | 1 | +| Dark-web banner | Serial / Tor-bound | 1 | + +### Network Fairness + +- Modules can apply jitter between bursts. +- Credential testing uses exponential back-off after repeated failures. +- `--throttle N` provides a global millisecond delay override. + +--- + +## 🌍 Environment Variables + +| Variable | Default | Purpose | +|---|---|---| +| `TOR_HOST` | `127.0.0.1` | Tor SOCKS5 host | +| `TOR_PORT` | `9050` | Tor SOCKS5 port | +| `SPECTRASCAN_HOME` | `~/.local/share/SpectraScan` | Report directory | +| `NUMVERIFY_KEY` | — | NumVerify API key | +| `SHODAN_KEY` | — | Shodan API key | +| `NVD_API_KEY` | — | NVD API key | +| `EMAILREP_KEY` | — | emailrep.io key | +| `HTTP_PROXY` | — | Global outbound HTTP proxy | + +--- + +## 🆚 Comparison + +SpectraScan is intended to complement established security tooling rather than +replace every specialized tool. + +| Capability | SpectraScan | Nmap | Recon-ng | SpiderFoot | +|---|:---:|:---:|:---:|:---:| +| Port scanning | ✅ | ✅ | ⚠️ | ❌ | +| OS fingerprinting | ✅ | ✅ | ⚠️ | ⚠️ | +| OSINT suite | ✅ | ❌ | ✅ | ✅ | +| SMB / LDAP / SNMP | ✅ | ⚠️ | ⚠️ | ❌ | +| Credential testing | ✅ | ❌ | ⚠️ | ❌ | +| CVE correlation | ✅ | ⚠️ | ⚠️ | ✅ | +| Dark-web recon | ✅ | ❌ | ⚠️ | ✅ | +| BTC profiling | ✅ | ❌ | ❌ | ⚠️ | +| HTML reports | ✅ | ⚠️ | ✅ | ✅ | +| Interactive CLI | ✅ | ❌ | ✅ | ❌ | + +--- + +## 🩺 Troubleshooting + +| Problem | Likely Cause | Fix | +|---|---|---| +| `Permission denied` on SYN scan | Missing privileges | Run with appropriate administrator/root privileges | +| `.onion` connection hangs | Tor unavailable / wrong port | Check `TOR_HOST` and `TOR_PORT` | +| `pysocks` missing | Optional dependency absent | `pip install pysocks` | +| TLS parsing is limited | `cryptography` missing | `pip install cryptography` | +| BTC APIs fail | Network / rate limit | Retry later or review proxy settings | +| Rich UI looks broken | Terminal capability issue | Use a 256-color capable terminal | +| `ModuleNotFoundError: requests` | Dependency missing | Install project requirements | +| Windows SYN scan fails | Raw-packet support unavailable | Install a compatible Npcap setup | +| Reports use the wrong folder | Custom path not configured | Set `SPECTRASCAN_HOME` | + +--- + ### Async Scanner Engine @@ -739,118 +739,133 @@ python SpectraScan.py -t scanme.nmap.org --async-scan python SpectraScan.py -t scanme.nmap.org --async-scan --async-concurrency 200 --async-timeout 0.75 ``` -## 🛣️ Roadmap - -- [x] Async scanner engine with `asyncio` + `aiohttp` -- [ ] Kerberos enumeration + AS-REP roasting detection -- [ ] Subdomain permutation engine -- [ ] Email-to-username correlation workflows -- [ ] WebSocket / GraphQL introspection -- [ ] Multi-user collaboration and scan sharing -- [ ] Machine-learning anomaly detection -- [ ] Official Docker image -- [ ] GitHub Actions CI/CD -- [ ] Plugin system for custom modules - ---- - -## 🛡️ Security Notes - -- Credential-testing modules include rate limiting, jitter, and timeout controls. -- External APIs are treated as optional dependencies and can fail gracefully. -- Some features require elevated privileges or platform-specific support. -- Cross-platform support targets Linux, Windows, and macOS. -- Dark Web Recon is designed for passive reconnaissance and does not - intentionally download binaries, interact with marketplaces, or execute payloads. -- **Authorization is your responsibility.** Always obtain permission before - probing external infrastructure. - ---- - -## 🤝 Contributing - -Contributions, bug reports, documentation improvements, protocol modules, tests, -and new legal OSINT data sources are welcome. - -```bash -# Fork the repository - -# Create a feature branch -git checkout -b feature/AmazingFeature - -# Run tests -pytest tests/ - -# Format -black . - -# Commit -git commit -m "feat: add AmazingFeature" - -# Push -git push origin feature/AmazingFeature -``` - -### 💡 Contribution Ideas - -- New protocol enumeration modules -- New free and legal OSINT sources -- Markdown / SARIF report exporters -- Bug fixes -- Test fixtures -- CI improvements -- Documentation improvements - -Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) before opening a large PR. - ---- - -## 🙏 Acknowledgements - -- [Nmap](https://nmap.org/) — timing-profile inspiration -- [Impacket](https://github.com/fortra/impacket) — protocol references -- [Rich](https://github.com/Textualize/rich) — terminal UI -- [Ahmia](https://ahmia.fi/) — clearnet dark-web search -- [Blockchair](https://blockchair.com/) / [Blockstream](https://blockstream.info/) — public Bitcoin APIs -- The Python security community ❤️ - ---- - -## ⚠️ Disclaimer - -**For educational and authorized security testing only.** - -SpectraScan is intended for security professionals, researchers, and learners -testing systems they own or have explicit permission to assess. - -Unauthorized scanning or enumeration may violate laws, contracts, network -policies, or terms of service. The developers are not responsible for misuse. - -By using SpectraScan, you agree to: - -1. Scan only systems you own or are explicitly authorized to test. -2. Follow applicable local, national, and international laws. -3. Avoid illegal content and unauthorized marketplace interaction. -4. Respect service limits and applicable API policies. - ---- - -## 📜 License - -This project is licensed under the **MIT License**. - -See [`LICENSE`](LICENSE) for the complete license text. - ---- - -<div align="center"> - -### 🕵️‍♂️ SpectraScan - -**Recon. Enumerate. Understand.** - -Made with ❤️ by **ItsWanheda & An0nym0us** - -[⬆ Back to top](#-spectrascan) - -</div> +## CLI hardening notes + +The one-shot CLI rejects unsupported arguments instead of silently ignoring them. Examples: + +```bash +python SpectraScan.py -t scanme.nmap.org -p 1-1000 -T T3 --scan-type tcp +python SpectraScan.py -t scanme.nmap.org -p 80,443 -o report.json +python SpectraScan.py -t 192.168.1.1 --scan-type udp -p 53,161 -o udp-report.json +python SpectraScan.py -t 192.168.1.0/24 -p common --exclude 192.168.1.10 --yes -o network.json +python SpectraScan.py -d example.com -o domain.json +python SpectraScan.py -i 8.8.8.8 -o ip-intelligence.json +``` + +CIDR scans have a hard address limit set by --max-targets (default 1024), and scans over 64 addresses require explicit --yes confirmation. Exclusions can be repeated. UDP silence is reported as open|filtered, not confirmed open. Historical reports use SPECTRASCAN_HOME (default ~/.local/share/SpectraScan) and exports use atomic writes. SSH credential checks require known-host trust or a pinned SHA256 fingerprint and never store plaintext passwords. + +## 🛣️ Roadmap + +- [x] Async scanner engine with `asyncio` + `aiohttp` +- [ ] Kerberos enumeration + AS-REP roasting detection +- [ ] Subdomain permutation engine +- [ ] Email-to-username correlation workflows +- [ ] WebSocket / GraphQL introspection +- [ ] Multi-user collaboration and scan sharing +- [ ] Machine-learning anomaly detection +- [ ] Official Docker image +- [ ] GitHub Actions CI/CD +- [ ] Plugin system for custom modules + +--- + +## 🛡️ Security Notes + +- Credential-testing modules include rate limiting, jitter, and timeout controls. +- External APIs are treated as optional dependencies and can fail gracefully. +- Some features require elevated privileges or platform-specific support. +- Cross-platform support targets Linux, Windows, and macOS. +- Dark Web Recon is designed for passive reconnaissance and does not + intentionally download binaries, interact with marketplaces, or execute payloads. +- **Authorization is your responsibility.** Always obtain permission before + probing external infrastructure. + +--- + +## 🤝 Contributing + +Contributions, bug reports, documentation improvements, protocol modules, tests, +and new legal OSINT data sources are welcome. + +```bash +# Fork the repository + +# Create a feature branch +git checkout -b feature/AmazingFeature + +# Run tests +pytest tests/ + +# Format +black . + +# Commit +git commit -m "feat: add AmazingFeature" + +# Push +git push origin feature/AmazingFeature +``` + +### 💡 Contribution Ideas + +- New protocol enumeration modules +- New free and legal OSINT sources +- Markdown / SARIF report exporters +- Bug fixes +- Test fixtures +- CI improvements +- Documentation improvements + +Please read [`CONTRIBUTING.md`](CONTRIBUTING.md) before opening a large PR. + +--- + +## 🙏 Acknowledgements + +- [Nmap](https://nmap.org/) — timing-profile inspiration +- [Impacket](https://github.com/fortra/impacket) — protocol references +- [Rich](https://github.com/Textualize/rich) — terminal UI +- [Ahmia](https://ahmia.fi/) — clearnet dark-web search +- [Blockchair](https://blockchair.com/) / [Blockstream](https://blockstream.info/) — public Bitcoin APIs +- The Python security community ❤️ + +--- + +## ⚠️ Disclaimer + +**For educational and authorized security testing only.** + +SpectraScan is intended for security professionals, researchers, and learners +testing systems they own or have explicit permission to assess. + +Unauthorized scanning or enumeration may violate laws, contracts, network +policies, or terms of service. The developers are not responsible for misuse. + +By using SpectraScan, you agree to: + +1. Scan only systems you own or are explicitly authorized to test. +2. Follow applicable local, national, and international laws. +3. Avoid illegal content and unauthorized marketplace interaction. +4. Respect service limits and applicable API policies. + +--- + +## 📜 License + +This project is licensed under the **MIT License**. + +See [`LICENSE`](LICENSE) for the complete license text. + +--- + +<div align="center"> + +### 🕵️‍♂️ SpectraScan + +**Recon. Enumerate. Understand.** + +Made with ❤️ by **ItsWanheda & An0nym0us** + +[⬆ Back to top](#-spectrascan) + +</div> From cd3b7f5c24bbf97259a769d759da3840a9b7b121 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:05:06 +0330 Subject: [PATCH 15/46] ci: add cross-platform tests and security auditing --- .github/workflows/ci.yml | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c1935f..0b9d393 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,27 +2,40 @@ name: CI on: push: - branches: [main, "feat/**"] + branches: [main, "feat/**", "fix/**"] pull_request: branches: [main] jobs: test: - runs-on: ubuntu-latest + name: Tests (${{ matrix.os }}, Python ${{ matrix.python-version }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12"] steps: - name: Checkout uses: actions/checkout@v4 - - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.11" - - - name: Install async scanner dependency - run: python -m pip install --upgrade pip aiohttp - + python-version: ${{ matrix.python-version }} + cache: pip + - name: Upgrade pip + run: python -m pip install --upgrade pip + - name: Install dependencies and security tools + run: python -m pip install -r requirements.txt pytest bandit pip-audit - name: Compile Python sources - run: python -m compileall -q . - - - name: Run tests + run: python -m compileall -q SpectraScan.py modules tests + - name: Startup/import smoke test + run: python -c "import SpectraScan; from modules.async_scanner import AsyncPortScanner; from modules.vuln_scanner import VulnScanner" + - name: Run regression tests run: python -m unittest discover -s tests -p "test_*.py" + - name: Static security checks for hardened modules + if: runner.os == 'Linux' + run: bandit -r modules/async_scanner.py modules/brute_forcer.py modules/vuln_scanner.py modules/report_store.py -ll + - name: Dependency vulnerability audit + if: runner.os == 'Linux' && matrix.python-version == '3.12' + run: pip-audit -r requirements.txt From 36504c5822b96e8459fea09495404659ac5f8a06 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:08:21 +0330 Subject: [PATCH 16/46] fix: correct host resolution and CLI option behavior --- SpectraScan.py | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/SpectraScan.py b/SpectraScan.py index 70dd8c2..2e336e0 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -16,6 +16,7 @@ import logging import math import queue +import threading import ssl import subprocess import re @@ -40,6 +41,7 @@ from modules.async_scanner import AsyncPortScanner from modules.report_store import ReportStore, atomic_write_text, atomic_write_json, safe_csv_cell +from modules.web_enumerator import WebEnumerator import io sys.path.append(os.path.join(os.path.dirname(__file__), "modules")) @@ -4385,19 +4387,21 @@ def normalize_target(target: str) -> str: def resolve_host(hostname: str) -> str: - """Resolve a single host/IP. CIDR networks are handled separately.""" + """Resolve a single host or IP. CIDR networks must use the network-scan path.""" target = normalize_target(hostname) - if not target: raise ValueError("Target cannot be empty") - try: - network = ipaddress.ip_network(target, strict=False) - raise ValueError(f"CIDR network detected: {network}") - except ValueError as exc: - if "CIDR network detected" in str(exc): - raise - + return str(ipaddress.ip_address(target)) + except ValueError: + pass + if "/" in target: + try: + network = ipaddress.ip_network(target, strict=False) + except ValueError: + pass + else: + raise ValueError(f"CIDR network detected: {network}") try: return socket.gethostbyname(target) except socket.gaierror as exc: @@ -6879,7 +6883,7 @@ def main(argv: Optional[List[str]] = None): if report is None: parser.error("domain scan could not validate the requested domain") if args.output: - saved = _write_cli_report(report, args.output, args.format or "json") + saved = _write_cli_report(report, args.output, args.format) console.print(f"[green][+] Report saved: {saved}[/green]") return 0 if args.ip: @@ -6893,7 +6897,7 @@ def main(argv: Optional[List[str]] = None): "timestamp": datetime.now().isoformat(), "output": manager._buffer, } if args.output: - saved = _write_cli_report(report, args.output, args.format or "json") + saved = _write_cli_report(report, args.output, args.format) console.print(f"[green][+] Report saved: {saved}[/green]") return 0 @@ -6923,10 +6927,10 @@ def main(argv: Optional[List[str]] = None): parser.error("async scans over 10000 ports require --yes") scan_kwargs = { - "timeout": args.timeout if args.timeout is not None else 1.0, + "timeout": args.timeout if args.timeout is not None else TIMING_PROFILES[args.timing]["timeout"], "threads": args.threads, "scan_type": args.scan_type, - "timing": "custom" if args.timeout is not None else args.timing, + "timing": "custom", "ports": ports, "decoys": [], "check_vulns": args.vuln_scan, "rate_limit": 0, } if is_network: @@ -6945,10 +6949,7 @@ def main(argv: Optional[List[str]] = None): "timestamp": datetime.now().isoformat(), "hosts": results, "open_ports": [], } else: - try: - resolved_ip = resolve_host(target) - except (ValueError, socket.gaierror) as exc: - parser.error(str(exc)) + resolved_ip = None if args.async_scan: scanner = AsyncPortScanner( target, ports=ports, timeout=args.async_timeout, @@ -6956,10 +6957,17 @@ def main(argv: Optional[List[str]] = None): ) try: scanner.scan() + resolved_ip = scanner.resolved_ip report = scanner.get_results() except (ValueError, OSError, asyncio.TimeoutError) as exc: parser.error(f"async scan failed: {exc}") else: + try: + resolved_ip = resolve_host(target) + if ipaddress.ip_address(resolved_ip).version == 6: + parser.error("single IPv6 targets require --async-scan; legacy TCP/SYN/UDP scanners are IPv4-only") + except ValueError as exc: + parser.error(str(exc)) scanner = PortScanner(target, **scan_kwargs) scanner.resolved_ip = resolved_ip scanner.scan() @@ -6974,7 +6982,7 @@ def main(argv: Optional[List[str]] = None): base_url = target if target.lower().startswith(("http://", "https://")) else "http://" + target report["web_enumeration"] = WebEnumerator(base_url, args.wordlist).run() if args.output: - saved = _write_cli_report(report, args.output, args.format or "json") + saved = _write_cli_report(report, args.output, args.format) console.print(f"[green][+] Report saved: {saved}[/green]") elif args.scan_type == "udp": console.print("[yellow]UDP silence is reported as open|filtered; it is not proof of an open port.[/yellow]") From 202088418811cf9f75bcfd4659edcaa4e5a0e9c0 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:08:24 +0330 Subject: [PATCH 17/46] fix: enforce fingerprint pinning before SSH authentication --- modules/brute_forcer.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/modules/brute_forcer.py b/modules/brute_forcer.py index bfe8c56..1d73dc7 100644 --- a/modules/brute_forcer.py +++ b/modules/brute_forcer.py @@ -68,7 +68,7 @@ def _record(self, outcome: Dict[str, Any]) -> None: "username": str(outcome.get("username", "")), "category": "authenticated", }) - elif outcome.get("status") == "error": + elif outcome.get("status") in {"error", "failed"}: self.error_counts[category] += 1 def check_ssh(self, username: str, password: str) -> Dict[str, Any]: @@ -78,10 +78,13 @@ def check_ssh(self, username: str, password: str) -> Dict[str, Any]: return {"status": "error", "category": "dependency_missing"} client = paramiko.SSHClient() try: - client.load_system_host_keys() - if self.known_hosts_path: - client.load_host_keys(str(Path(self.known_hosts_path).expanduser())) expected = (self.expected_host_fingerprint or "").strip() + # In fingerprint-pinning mode do not load existing known-host keys: + # Paramiko would accept a known key before invoking MissingHostKeyPolicy. + if not expected: + client.load_system_host_keys() + if self.known_hosts_path: + client.load_host_keys(str(Path(self.known_hosts_path).expanduser())) if expected: class PinnedHostKeyPolicy(paramiko.MissingHostKeyPolicy): def missing_host_key(policy_self, ssh_client, hostname, key): From e32c99f1917e148a0360e0bf609172e1a37e63dd Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:08:28 +0330 Subject: [PATCH 18/46] fix: reject boolean values as async ports --- modules/async_scanner.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/modules/async_scanner.py b/modules/async_scanner.py index 0bfa9ed..4239b21 100644 --- a/modules/async_scanner.py +++ b/modules/async_scanner.py @@ -34,8 +34,11 @@ def __init__( raise ValueError(f"timeout must be between 0 and {MAX_TIMEOUT:g} seconds") if isinstance(concurrency, bool) or not 1 <= int(concurrency) <= MAX_CONCURRENCY: raise ValueError(f"concurrency must be between 1 and {MAX_CONCURRENCY}") + raw_ports = list(ports or []) + if any(isinstance(port, bool) for port in raw_ports): + raise ValueError("ports must be integers in the range 1..65535") try: - normalized_ports = sorted({int(port) for port in (ports or [])}) + normalized_ports = sorted({int(port) for port in raw_ports}) except (TypeError, ValueError, OverflowError) as exc: raise ValueError("ports must be integers in the range 1..65535") from exc if not normalized_ports: From d8fe4028e1b50b4a342745fecbd5b124b36874b6 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:08:30 +0330 Subject: [PATCH 19/46] fix: prevent report-store path traversal on writes --- modules/report_store.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/modules/report_store.py b/modules/report_store.py index 2e50465..8953962 100644 --- a/modules/report_store.py +++ b/modules/report_store.py @@ -65,11 +65,22 @@ def __init__(self, directory: Optional[os.PathLike[str] | str] = None) -> None: self.directory = Path(directory).expanduser().resolve() if directory else get_report_dir() self.directory.mkdir(parents=True, exist_ok=True) + def _safe_new_path(self, filename: str) -> Path: + candidate = Path(filename) + if candidate.name != filename or filename in {"", ".", ".."}: + raise ValueError("Report name must be a plain filename") + path = self.directory / filename + if path.is_symlink(): + raise ValueError("Refusing to overwrite a symbolic link") + if path.resolve().parent != self.directory: + raise ValueError("Report path escapes the report directory") + return path + def save_text(self, filename: str, content: str) -> str: - return atomic_write_text(self.directory / filename, content) + return atomic_write_text(self._safe_new_path(filename), content) def save_json(self, filename: str, data: Any) -> str: - return atomic_write_json(self.directory / filename, data) + return atomic_write_json(self._safe_new_path(filename), data) def list_reports(self) -> list[str]: return sorted( From 5a96d318e16c55a918d5f9627a69f78c4496a340 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:08:34 +0330 Subject: [PATCH 20/46] fix: avoid overstating version-based CVE matches --- modules/vuln_scanner.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/modules/vuln_scanner.py b/modules/vuln_scanner.py index 5f0eee1..dca9458 100644 --- a/modules/vuln_scanner.py +++ b/modules/vuln_scanner.py @@ -19,7 +19,11 @@ def _version_key(value: str) -> Optional[tuple]: if not value or not re.fullmatch(r"\d+(?:\.\d+){0,7}", value): return None try: - return tuple(int(part) for part in value.split(".")) + parts = [int(part) for part in value.split(".")] + # Treat release notation such as 9.1 and 9.1.0 as equivalent. + while len(parts) < 8: + parts.append(0) + return tuple(parts) except ValueError: return None @@ -160,7 +164,9 @@ def _match_evidence(self, cve: Dict[str, Any]) -> tuple[str, list[str]]: for key in ("versionStartIncluding", "versionStartExcluding", "versionEndIncluding", "versionEndExcluding"): if matched.get(key): evidence.append(f"{key}={matched[key]}") - return "confirmed", evidence + # Static version/CPE correlation is evidence of potential exposure, not live confirmation. + evidence.append("This is a version-based correlation only; verify exploit preconditions before treating it as confirmed.") + return "potential", evidence if self.cpe: evidence.append("NVD returned the exact CPE query, but no corroborating affected-version range was exposed.") return "potential", evidence From 9c328f3c8e31fd2ad5c94ee8c2c0b3bb23ea50f4 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:08:36 +0330 Subject: [PATCH 21/46] test: classify passive CPE matches as potential --- tests/test_vuln_scanner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_vuln_scanner.py b/tests/test_vuln_scanner.py index c5c745e..348c47f 100644 --- a/tests/test_vuln_scanner.py +++ b/tests/test_vuln_scanner.py @@ -42,7 +42,7 @@ def test_exact_cpe_version_range_classified_confirmed(self): }] } classification, evidence = scanner._match_evidence(cve) - self.assertEqual(classification, "confirmed") + self.assertEqual(classification, "potential") self.assertTrue(evidence) def test_keyword_only_match_is_not_confirmed(self): From cf5dc786289445d51c2bbcfd5079bd3f5f32cf71 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:10:00 +0330 Subject: [PATCH 22/46] fix: require real SYN probes and cap nested network workers --- SpectraScan.py | 96 +++++++++++++++++++++++--------------------------- 1 file changed, 45 insertions(+), 51 deletions(-) diff --git a/SpectraScan.py b/SpectraScan.py index 2e336e0..5573f3c 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -4515,72 +4515,60 @@ def wait(self): # ============== SYN Scanner ============== class SYNScan: - __slots__ = ("is_admin",) - def __init__(self): self.is_admin = os.geteuid() == 0 if hasattr(os, "geteuid") else False + if sys.platform == "win32": + try: + import ctypes + self.is_admin = bool(ctypes.windll.shell32.IsUserAnAdmin()) + except Exception: + self.is_admin = False def create_packet(self, src_ip: str, dst_ip: str, dst_port: int) -> bytes: ip_header = struct.pack( - "!BBHHHBBH4s4s", - 0x45, - 0, - 40, - random.randint(1, 65535), - 0, - 64, - 6, - 0, - socket.inet_aton(src_ip), - socket.inet_aton(dst_ip), + "!BBHHHBBH4s4s", 0x45, 0, 40, random.randint(1, 65535), + 0, 64, 6, 0, socket.inet_aton(src_ip), socket.inet_aton(dst_ip), ) tcp_header = struct.pack( - "!HHLLBBHHH", - random.randint(1024, 65535), - dst_port, - random.randint(0, 4294967295), - 0, - 0x50, - 0x02, - 65535, - 0, - 0, + "!HHLLBBHHH", random.randint(1024, 65535), dst_port, + random.randint(0, 4294967295), 0, 0x50, 0x02, 65535, 0, 0, ) return ip_header + tcp_header def scan(self, target_ip: str, port: int, timeout: float = 2.0) -> str: - # Fall back to TCP connect scan if we don't have raw socket privileges + """Perform a real half-open SYN probe; never silently fall back to TCP connect.""" if not self.is_admin: - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(timeout) - rc = sock.connect_ex((target_ip, port)) - sock.close() - if rc == 0: - return "open" - elif rc in (111, 10061): # ECONNREFUSED - return "closed" - else: - return "filtered" - except socket.timeout: - return "filtered" - except: - return "error" + return "error" try: - sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW) - sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) - sock.settimeout(timeout) - packet = self.create_packet(get_local_ip(), target_ip, port) - sock.sendto(packet, (target_ip, 0)) - data, _ = sock.recvfrom(1024) - sock.close() - tcp_flags = data[20 + 13] - if tcp_flags & 0x12: + from scapy.all import IP, TCP, RandShort, sr1, send + source_port = int(RandShort()) + probe = IP(dst=target_ip) / TCP(sport=source_port, dport=port, flags="S") + reply = sr1(probe, timeout=timeout, verbose=0) + if reply is None: + return "filtered" + if not reply.haslayer(TCP): + return "filtered" + flags = int(reply[TCP].flags) + if flags & 0x12 == 0x12: + # Reset the half-open connection after observing a SYN-ACK. + try: + reset = IP(dst=target_ip) / TCP( + sport=source_port, dport=port, flags="R", seq=int(reply[TCP].ack) + ) + send(reset, verbose=0) + except Exception: + pass return "open" - elif tcp_flags & 0x14 or tcp_flags & 0x04: + if flags & 0x04 or flags & 0x14: return "closed" return "filtered" - except: + except ImportError: + return "error" + except PermissionError: + return "error" + except OSError: + return "error" + except Exception: return "error" # ============== UDP Scanner ============== @@ -6462,6 +6450,12 @@ def run_network_port_scan( for excluded in excluded_networks: if excluded.version != network.version: raise ValueError(f"Exclusion {excluded} uses a different IP version from {network}.") + # Avoid multiplying host workers by a full-size port pool per host. + bounded_scan_kwargs = dict(scan_kwargs) + bounded_scan_kwargs["threads"] = min( + max(1, int(bounded_scan_kwargs.get("threads", 100))), + max(1, 128 // int(host_workers)), + ) work_queue = queue.Queue(maxsize=max(2, host_workers * 2)) sentinel = object() results = [] @@ -6488,7 +6482,7 @@ def worker(): return ip_text = str(item) try: - scanner = PortScanner(ip_text, **scan_kwargs) + scanner = PortScanner(ip_text, **bounded_scan_kwargs) scanner.resolved_ip = ip_text scanner.scan() if scanner.results: From 1294387e7319480d9e92b4fbe633fbdf35a2a7b9 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:10:03 +0330 Subject: [PATCH 23/46] fix: validate SSH host fingerprint configuration --- modules/brute_forcer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/brute_forcer.py b/modules/brute_forcer.py index 1d73dc7..e22454e 100644 --- a/modules/brute_forcer.py +++ b/modules/brute_forcer.py @@ -40,6 +40,8 @@ def __init__( raise ValueError("port must be in the range 1..65535") if self.service not in {"ssh", "ftp"}: raise ValueError("service must be 'ssh' or 'ftp'") + if self.expected_host_fingerprint and not self.expected_host_fingerprint.startswith("SHA256:"): + raise ValueError("expected_host_fingerprint must use the OpenSSH SHA256: format") if isinstance(max_threads, bool) or not 1 <= int(max_threads) <= 32: raise ValueError("max_threads must be between 1 and 32") self.max_threads = int(max_threads) From 013302e8e20d905a33b2e4825eedcd265b7a4d3f Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:10:06 +0330 Subject: [PATCH 24/46] test: verify tcp syn and udp scan mode dispatch --- tests/test_scan_modes.py | 46 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/test_scan_modes.py diff --git a/tests/test_scan_modes.py b/tests/test_scan_modes.py new file mode 100644 index 0000000..78fe9ff --- /dev/null +++ b/tests/test_scan_modes.py @@ -0,0 +1,46 @@ +"""Regression tests that verify scanner mode dispatch.""" +import unittest +from unittest.mock import patch + +import SpectraScan + + +class FakeUdp: + def scan(self, target, port, timeout=3.0): + return {"state": "open", "service": ("DNS", "DNS Query"), "response": "abcd"} + + +class TestScanModes(unittest.TestCase): + def test_udp_option_uses_udp_probe(self): + scanner = SpectraScan.PortScanner( + "127.0.0.1", scan_type="udp", ports=[53], timing="custom", threads=1 + ) + scanner.resolved_ip = "127.0.0.1" + with patch.object(SpectraScan, "UDPScan", return_value=FakeUdp()): + result = scanner.scan_port(53) + self.assertEqual(result["protocol"], "udp") + self.assertEqual(result["state"], "open") + self.assertEqual(result["service"], "DNS") + + def test_syn_option_uses_syn_scanner(self): + scanner = SpectraScan.PortScanner( + "127.0.0.1", scan_type="syn", ports=[22], timing="custom", threads=1 + ) + scanner.resolved_ip = "127.0.0.1" + with patch.object(SpectraScan.SYNScan, "scan", return_value="closed") as probe: + result = scanner.scan_port(22) + probe.assert_called_once_with("127.0.0.1", 22, timeout=scanner.timeout) + self.assertEqual(result["protocol"], "tcp") + self.assertEqual(result["state"], "closed") + + def test_syn_scan_does_not_silently_fall_back_to_tcp_connect(self): + scanner = SpectraScan.SYNScan() + scanner.is_admin = False + with patch("socket.socket") as socket_factory: + state = scanner.scan("127.0.0.1", 80) + self.assertEqual(state, "error") + socket_factory.assert_not_called() + + +if __name__ == "__main__": + unittest.main() From aa9ed20965f5974c04e767989b4931ff4f05be56 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:11:16 +0330 Subject: [PATCH 25/46] fix: validate host fingerprint after initialization --- modules/brute_forcer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/brute_forcer.py b/modules/brute_forcer.py index e22454e..024ead2 100644 --- a/modules/brute_forcer.py +++ b/modules/brute_forcer.py @@ -40,13 +40,13 @@ def __init__( raise ValueError("port must be in the range 1..65535") if self.service not in {"ssh", "ftp"}: raise ValueError("service must be 'ssh' or 'ftp'") - if self.expected_host_fingerprint and not self.expected_host_fingerprint.startswith("SHA256:"): - raise ValueError("expected_host_fingerprint must use the OpenSSH SHA256: format") if isinstance(max_threads, bool) or not 1 <= int(max_threads) <= 32: raise ValueError("max_threads must be between 1 and 32") self.max_threads = int(max_threads) self.known_hosts_path = known_hosts_path self.expected_host_fingerprint = expected_host_fingerprint + if self.expected_host_fingerprint and not self.expected_host_fingerprint.startswith("SHA256:"): + raise ValueError("expected_host_fingerprint must use the OpenSSH SHA256: format") self.results: list[Dict[str, Any]] = [] self.error_counts: Counter[str] = Counter() self.lock = threading.Lock() @@ -151,6 +151,8 @@ def check_ftp(self, username: str, password: str) -> Dict[str, Any]: pass def run(self) -> list[Dict[str, Any]]: + self.results = [] + self.error_counts.clear() wordlist = self.load_wordlist() if not wordlist: return [] From 7dcec8195746b7a2c3ed299b71949640a212b582 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:11:20 +0330 Subject: [PATCH 26/46] fix: fail clearly when SYN scan lacks privileges --- SpectraScan.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/SpectraScan.py b/SpectraScan.py index 5573f3c..3e353a0 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -6547,6 +6547,10 @@ def run_port_scan_cli(): default="tcp", ) + if scan_type == "syn" and not SYNScan().is_admin: + console.print("[!] SYN scans require root/administrator privileges. Returning to menu.", style="bold red") + return + timing = Prompt.ask( "Timing Profile", choices=["T0", "T1", "T2", "T3", "T4", "T5"], @@ -6862,6 +6866,8 @@ def main(argv: Optional[List[str]] = None): parser.error("--async-scan supports only --scan-type tcp") if args.async_scan and args.timeout is not None: parser.error("use --async-timeout with --async-scan instead of --timeout") + if args.scan_type == "syn" and not SYNScan().is_admin: + parser.error("--scan-type syn requires root/administrator privileges; no TCP-connect fallback is performed") if args.web_enum and not args.wordlist: parser.error("--web-enum requires --wordlist") if args.format and not args.output: From 22ed20bfecc4893bfa273c16d04dfae547f3edce Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:11:23 +0330 Subject: [PATCH 27/46] test: cover output extension format inference --- tests/test_cli.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index dcc51a0..d66aa26 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -77,6 +77,17 @@ def test_unknown_argument_is_rejected(self): self.assertNotEqual(result.returncode, 0) self.assertIn("unrecognized arguments", result.stderr) + def test_html_extension_selects_html_without_format_flag(self): + port = self._listener() + with tempfile.TemporaryDirectory() as temp: + output = Path(temp) / "scan.html" + result = self._run( + "-t", "127.0.0.1", "-p", str(port), "--threads", "2", + "--timeout", "0.3", "-o", output, cwd=temp, + ) + self.assertEqual(result.returncode, 0, msg=result.stderr + result.stdout) + self.assertTrue(output.read_text(encoding="utf-8").lstrip().lower().startswith("<!doctype html>")) + def test_invalid_port_range_is_rejected_before_network_access(self): with tempfile.TemporaryDirectory() as temp: result = self._run("-t", "127.0.0.1", "-p", "0,70000", cwd=temp) From f1f12b6bd1898d63cae750f10b32021a18fbb1e6 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:13:22 +0330 Subject: [PATCH 28/46] fix: cap credential wordlist processing --- modules/brute_forcer.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/modules/brute_forcer.py b/modules/brute_forcer.py index 024ead2..274925f 100644 --- a/modules/brute_forcer.py +++ b/modules/brute_forcer.py @@ -31,6 +31,7 @@ def __init__( self, target: str, port: int, service: str, wordlist_path: str, max_threads: int = 10, known_hosts_path: Optional[str] = None, expected_host_fingerprint: Optional[str] = None, + max_passwords: int = 100, ) -> None: self.target = str(target) self.port = int(port) @@ -45,6 +46,9 @@ def __init__( self.max_threads = int(max_threads) self.known_hosts_path = known_hosts_path self.expected_host_fingerprint = expected_host_fingerprint + if isinstance(max_passwords, bool) or not 1 <= int(max_passwords) <= 1000: + raise ValueError("max_passwords must be between 1 and 1000") + self.max_passwords = int(max_passwords) if self.expected_host_fingerprint and not self.expected_host_fingerprint.startswith("SHA256:"): raise ValueError("expected_host_fingerprint must use the OpenSSH SHA256: format") self.results: list[Dict[str, Any]] = [] @@ -53,8 +57,16 @@ def __init__( def load_wordlist(self) -> list[str]: try: + words = [] with open(self.wordlist_path, "r", encoding="utf-8", errors="ignore") as stream: - return [line.strip() for line in stream if line.strip()] + for line in stream: + candidate = line.strip() + if not candidate: + continue + words.append(candidate) + if len(words) >= self.max_passwords: + break + return words except OSError as exc: logger.error("Unable to read credential wordlist (%s)", type(exc).__name__) return [] From 39dabfed4e3761bf6e80c5bbed12dc2e8bc36ea2 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:15:03 +0330 Subject: [PATCH 29/46] feat: route documented CLI OSINT and protocol flags --- SpectraScan.py | 187 +++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 150 insertions(+), 37 deletions(-) diff --git a/SpectraScan.py b/SpectraScan.py index 3e353a0..c72d502 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -42,6 +42,10 @@ from modules.async_scanner import AsyncPortScanner from modules.report_store import ReportStore, atomic_write_text, atomic_write_json, safe_csv_cell from modules.web_enumerator import WebEnumerator +from modules.brute_forcer import BruteForcer +from modules.smb_enum import SMBEnumerator +from modules.ldap_enum import LDAPEnumerator +from modules.dns_zone import DNSZone import io sys.path.append(os.path.join(os.path.dirname(__file__), "modules")) @@ -6166,7 +6170,7 @@ def scan(cls, domain: str, report_manager: ReportManager): class IPScanner: """Integrates SpectraScan IP Scanner features""" @staticmethod - def scan(ip: str, report_manager: ReportManager): + def scan(ip: str, report_manager: ReportManager, include_shodan: Optional[bool] = None): report_manager.write(f"\n-----IP SCAN OF {ip}-----\n") # GeoIP @@ -6188,29 +6192,33 @@ def scan(ip: str, report_manager: ReportManager): report_manager.write("\n[*] ADMIN INFO (WHOIS)") report_manager.write(DomainScanner._run_whois(ip)) - # Shodan - report_manager.write("\n[*] SHODAN RESULTS") - api_key = os.environ.get("SHODAN_API_KEY") - if not api_key: - report_manager.write("[-] SHODAN_API_KEY environment variable not set. Skipping Shodan lookup.") + # Shodan: optional in one-shot CLI mode, legacy auto mode in interactive menu. + if include_shodan is False: + report_manager.write("\n[*] SHODAN RESULTS") + report_manager.write("[*] Shodan lookup disabled. Pass --shodan to request enrichment.") else: - try: - import shodan - api = shodan.Shodan(api_key) - result = api.host(ip) - ports = result.get("ports", []) - report_manager.write(f"Open Ports: {len(ports)}") - for port in ports: - banner = "" - for entry in result.get("data", []): - if entry.get("port") == port: - banner = entry.get("product", "Unknown") - break - report_manager.write(f" Port {port}: {banner}") - except ImportError: - report_manager.write("[-] Shodan Python library not found. Install with: pip install shodan") - except Exception as e: - report_manager.write(f"Error with Shodan: {e}") + report_manager.write("\n[*] SHODAN RESULTS") + api_key = os.environ.get("SHODAN_API_KEY") + if not api_key: + report_manager.write("[-] SHODAN_API_KEY environment variable not set. Skipping Shodan lookup.") + else: + try: + import shodan + api = shodan.Shodan(api_key) + result = api.host(ip) + ports = result.get("ports", []) + report_manager.write(f"Open Ports: {len(ports)}") + for port in ports: + banner = "" + for entry in result.get("data", []): + if entry.get("port") == port: + banner = entry.get("product", "Unknown") + break + report_manager.write(f" Port {port}: {banner}") + except ImportError: + report_manager.write("[-] Shodan Python library not found. Install with: pip install shodan") + except Exception as e: + report_manager.write(f"Error with Shodan: {type(e).__name__}") report_manager.write("\n[*] DONE") @@ -6229,7 +6237,7 @@ def scan(phone: str, report_manager: ReportManager): if os.path.exists(script_path): try: result = subprocess.run( - ["python3", script_path, phone], + [sys.executable, script_path, phone], capture_output=True, text=True, timeout=60, @@ -6795,6 +6803,9 @@ def _cli_parser(): targets.add_argument("-t", "--target", help="IP address, hostname, URL, or CIDR network") targets.add_argument("-d", "--domain", help="Run passive-first domain intelligence") targets.add_argument("-i", "--ip", help="Run IP intelligence") + targets.add_argument("-e", "--email", help="Check public email reputation") + targets.add_argument("--phone", help="Look up a phone number") + targets.add_argument("--image", help="Extract metadata from a local image") parser.add_argument("-p", "--ports", default="common", help="Ports: comma list, range (1-1024), common, or all") parser.add_argument("-T", "--timing", choices=sorted(TIMING_PROFILES), default="T3", help="Port-scan timing profile") parser.add_argument("--scan-type", choices=("tcp", "syn", "udp"), default="tcp", help="Select TCP connect, SYN, or UDP probing") @@ -6807,6 +6818,17 @@ def _cli_parser(): parser.add_argument("--os-detect", action="store_true", help="Record TTL-based OS fingerprint evidence") parser.add_argument("--web-enum", action="store_true", help="Enumerate web paths using --wordlist") parser.add_argument("--wordlist", help="Wordlist for --web-enum") + parser.add_argument("-l", "--links", action="store_true", help="Collect links for a domain target") + parser.add_argument("--shodan", action="store_true", help="Request Shodan enrichment (requires -i/--ip and SHODAN_API_KEY)") + parser.add_argument("--brute-force", action="store_true", help="Run bounded SSH/FTP credential validation against an authorized target") + parser.add_argument("--service", choices=("ssh", "ftp"), help="Service for --brute-force") + parser.add_argument("--port", type=int, help="Service port for --brute-force") + parser.add_argument("--max-attempts", type=int, default=100, help="Maximum passwords from the wordlist (1-1000)") + parser.add_argument("--ssh-known-hosts", help="Optional SSH known_hosts file") + parser.add_argument("--ssh-host-fingerprint", help="Pin SSH host key using OpenSSH SHA256: fingerprint") + parser.add_argument("--smb", action="store_true", help="Run SMB enumeration against --target") + parser.add_argument("--ldap", action="store_true", help="Run LDAP enumeration against --target") + parser.add_argument("--dns-zone", action="store_true", help="Check DNS zone transfer against --domain") parser.add_argument("-o", "--output", help="Write a report to this path") parser.add_argument("-f", "--format", choices=("json", "html", "csv"), help="Report format; inferred from output extension if omitted") parser.add_argument("--exclude", action="append", default=[], help="Exclude an IP/CIDR from a network scan; repeatable") @@ -6854,8 +6876,33 @@ def main(argv: Optional[List[str]] = None): if argv: parser = _cli_parser() args = parser.parse_args(argv) - if not any((args.target, args.domain, args.ip)): - parser.error("choose one target with -t/--target, -d/--domain, or -i/--ip") + if not any((args.target, args.domain, args.ip, args.email, args.phone, args.image)): + parser.error("choose one target using -t/--target, -d/--domain, -i/--ip, -e/--email, --phone, or --image") + if args.shodan and not args.ip: + parser.error("--shodan requires -i/--ip") + if args.links and not (args.domain or args.target): + parser.error("--links requires -d/--domain or -t/--target") + if args.dns_zone and not args.domain: + parser.error("--dns-zone requires -d/--domain") + if (args.smb or args.ldap or args.brute_force) and not args.target: + parser.error("--smb, --ldap, and --brute-force require -t/--target") + if args.brute_force: + if not args.wordlist or not args.service or args.port is None: + parser.error("--brute-force requires --wordlist, --service ssh|ftp, and --port") + if not args.yes: + parser.error("--brute-force requires explicit confirmation with --yes") + if not 1 <= args.port <= 65535: + parser.error("--port must be between 1 and 65535") + if not 1 <= args.max_attempts <= 1000: + parser.error("--max-attempts must be between 1 and 1000") + if args.service != "ssh" and args.ssh_host_fingerprint: + parser.error("--ssh-host-fingerprint is only valid with --service ssh") + elif args.service or args.port is not None or args.ssh_known_hosts or args.ssh_host_fingerprint: + parser.error("--service, --port, and SSH host-key options require --brute-force") + if args.email and not EMAIL_REGEX.fullmatch(args.email): + parser.error("--email must be a valid email address") + if args.phone and detect_target_type(args.phone).get("type") != "phone": + parser.error("--phone must be a phone number in a recognized format") if not 1 <= args.max_targets <= 65536: parser.error("--max-targets must be between 1 and 65536") if not 1 <= args.threads <= 512: @@ -6878,30 +6925,90 @@ def main(argv: Optional[List[str]] = None): parser.error("--format conflicts with output file extension") manager = ReportManager() - if args.domain: - report = DomainScanner.scan(args.domain, manager) - if report is None: - parser.error("domain scan could not validate the requested domain") + + def finish_report(report): if args.output: saved = _write_cli_report(report, args.output, args.format) console.print(f"[green][+] Report saved: {saved}[/green]") + else: + console.print(json.dumps(report, indent=2, ensure_ascii=False, default=str), markup=False) return 0 + + if args.email: + EmailScanner.scan(args.email, manager) + return finish_report({ + "module": "Email Intelligence", "target": args.email, + "timestamp": datetime.now().isoformat(), "output": manager._buffer, + }) + if args.phone: + PhoneScanner.scan(args.phone, manager) + return finish_report({ + "module": "Phone Intelligence", "target": args.phone, + "timestamp": datetime.now().isoformat(), "output": manager._buffer, + }) + if args.image: + ImageScanner.scan(args.image, manager) + return finish_report({ + "module": "Image Metadata", "target": args.image, + "timestamp": datetime.now().isoformat(), "output": manager._buffer, + }) + if args.domain: + domain_report = DomainScanner.scan(args.domain, manager) + if domain_report is None: + parser.error("domain scan could not validate the requested domain") + report = domain_report + if args.links: + LinkScanner.scan(args.domain, manager) + report = {"target": args.domain, "domain_intelligence": domain_report, "link_output": manager._buffer} + if args.dns_zone: + report = { + "target": args.domain, "domain_intelligence": domain_report, + "dns_zone": DNSZone.scan(args.domain), "output": manager._buffer, + } + return finish_report(report) if args.ip: try: ipaddress.ip_address(args.ip) except ValueError: parser.error("--ip must be a valid IPv4 or IPv6 address") - IPScanner.scan(args.ip, manager) - report = { + IPScanner.scan(args.ip, manager, include_shodan=args.shodan) + return finish_report({ "module": "IP Intelligence", "target": args.ip, + "shodan_requested": args.shodan, "timestamp": datetime.now().isoformat(), "output": manager._buffer, - } - if args.output: - saved = _write_cli_report(report, args.output, args.format) - console.print(f"[green][+] Report saved: {saved}[/green]") - return 0 + }) target = normalize_target(args.target) + if args.smb or args.ldap or args.brute_force: + if "/" in target: + parser.error("protocol enumeration and credential validation require a single host, not a CIDR") + try: + resolved_ip = resolve_host(target) + except (ValueError, socket.gaierror) as exc: + parser.error(str(exc)) + modules = {} + if args.smb: + modules["smb"] = SMBEnumerator.scan(resolved_ip) + if args.ldap: + modules["ldap"] = LDAPEnumerator.scan(resolved_ip) + if args.brute_force: + brute = BruteForcer( + resolved_ip, args.port, args.service, args.wordlist, + max_threads=min(args.threads, 32), + known_hosts_path=args.ssh_known_hosts, + expected_host_fingerprint=args.ssh_host_fingerprint, + max_passwords=args.max_attempts, + ) + modules["credential_validation"] = { + "target": resolved_ip, "port": args.port, "service": args.service, + "passwords_checked_per_username": len(brute.load_wordlist()), + "results": brute.run(), + "error_counts": dict(brute.error_counts), + } + return finish_report({ + "target": target, "resolved_ip": resolved_ip, "modules": modules, + "timestamp": datetime.now().isoformat(), + }) if not target: parser.error("target cannot be empty") try: @@ -6910,6 +7017,12 @@ def main(argv: Optional[List[str]] = None): except ValueError: network = None is_network = False + if args.links: + parser.error("--links is supported with --domain; use -d/--domain") + if args.shodan or args.dns_zone or args.smb or args.ldap or args.brute_force: + parser.error("the selected module option is incompatible with this scan") + if args.phone or args.image or args.email: + parser.error("OSINT options cannot be combined with a port scan") if args.scan_type == "udp" and args.ports.strip().lower() == "common": ports = sorted(UDPScan().common_udp_ports) else: From 08d03f3d483128e13c0410240d11af129e92fab4 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:17:01 +0330 Subject: [PATCH 30/46] fix: use FTPS by default for credential checks --- modules/brute_forcer.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/modules/brute_forcer.py b/modules/brute_forcer.py index 274925f..287da4c 100644 --- a/modules/brute_forcer.py +++ b/modules/brute_forcer.py @@ -32,6 +32,7 @@ def __init__( max_threads: int = 10, known_hosts_path: Optional[str] = None, expected_host_fingerprint: Optional[str] = None, max_passwords: int = 100, + allow_insecure_ftp: bool = False, ) -> None: self.target = str(target) self.port = int(port) @@ -49,6 +50,7 @@ def __init__( if isinstance(max_passwords, bool) or not 1 <= int(max_passwords) <= 1000: raise ValueError("max_passwords must be between 1 and 1000") self.max_passwords = int(max_passwords) + self.allow_insecure_ftp = bool(allow_insecure_ftp) if self.expected_host_fingerprint and not self.expected_host_fingerprint.startswith("SHA256:"): raise ValueError("expected_host_fingerprint must use the OpenSSH SHA256: format") self.results: list[Dict[str, Any]] = [] @@ -139,11 +141,16 @@ def missing_host_key(policy_self, ssh_client, hostname, key): pass def check_ftp(self, username: str, password: str) -> Dict[str, Any]: - import ftplib - ftp = ftplib.FTP() + # FTP is plaintext unless explicit FTPS is used. The insecure path must be + # deliberately opted into by the caller for isolated legacy test systems. + import ftplib # nosec B402 - FTPS is the default; plain FTP requires explicit opt-in. + + ftp = (ftplib.FTP() if self.allow_insecure_ftp else ftplib.FTP_TLS()) # nosec B321 - plain FTP is explicit opt-in only try: ftp.connect(self.target, self.port, timeout=5) ftp.login(username, password) + if not self.allow_insecure_ftp: + ftp.prot_p() return {"status": "success", "category": "authenticated", "username": username} except ftplib.error_perm: return {"status": "failed", "category": "invalid_credentials"} @@ -161,7 +168,6 @@ def check_ftp(self, username: str, password: str) -> Dict[str, Any]: ftp.close() except Exception: pass - def run(self) -> list[Dict[str, Any]]: self.results = [] self.error_counts.clear() From 7edb59c7e33cc082f5342018ecd50d64198920fb Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:17:03 +0330 Subject: [PATCH 31/46] fix: remove committed phone API key and repair phone lookup --- modules/phone_locator.py | 42 +++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/modules/phone_locator.py b/modules/phone_locator.py index 66e152c..38d9d6b 100644 --- a/modules/phone_locator.py +++ b/modules/phone_locator.py @@ -2,7 +2,7 @@ import sys import requests import json -import time +import os class color: PURPLE = '\033[95m' @@ -17,21 +17,35 @@ class color: END = '\033[0m' class config: - # Note: You should replace this with your own API key from numverify.com - key = "83c4959db3119ceb89d4390911a5ce0e" + # Set NUMVERIFY_KEY in the environment; never commit provider credentials. + key = os.environ.get("NUMVERIFY_KEY", "").strip() def main(): if len(sys.argv) != 2: print(f"Usage: python {sys.argv} <phone_number>") sys.exit(1) - number = sys.argv - api = f"http://apilayer.net/api/validate?access_key={config.key}&number={number}&country_code=&format=1" - + number = sys.argv[1].strip() + if not config.key: + print("NUMVERIFY_KEY is not configured; set it in the environment.") + sys.exit(2) + if not number: + print("A non-empty phone number is required.") + sys.exit(2) + try: - output = requests.get(api) - content = output.text - obj = json.loads(content) + output = requests.get( + "https://api.apilayer.net/api/validate", + params={ + "access_key": config.key, + "number": number, + "country_code": "", + "format": 1, + }, + timeout=10, + ) + output.raise_for_status() + obj = output.json() country_code = obj.get('country_code', '') country_name = obj.get('country_name', '') @@ -39,16 +53,11 @@ def main(): carrier = obj.get('carrier', '') line_type = obj.get('line_type', None) - time.sleep(0.2) print(f" - Getting Country [{'OK' if country_code else 'FAILED'}]") - time.sleep(0.2) print(f" - Getting Country Name [{'OK' if country_name else 'FAILED'}]") - time.sleep(0.2) print(f" - Getting Location [{'OK' if location else 'FAILED'}]") - time.sleep(0.2) print(f" - Getting Carrier [{'OK' if carrier else 'FAILED'}]") - time.sleep(0.2) - print(f" - Getting Device [{'OK' if line_type else 'FAILED'}]") + print(f" - Getting Device [{'OK' if line_type else 'FAILED'}] print(f"\n{color.YELLOW}[+] Information Output{color.END}") print("--------------------------------------") @@ -60,7 +69,8 @@ def main(): print(f" - Device: {line_type}") except Exception as e: - print(f"Error: {e}") + # Avoid rendering request URLs or API credentials from provider exceptions. + print(f"Phone lookup failed: {type(e).__name__}") if __name__ == "__main__": main() \ No newline at end of file From 87217513e37acbbba75bda2f71a7ede3551b0497 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:17:08 +0330 Subject: [PATCH 32/46] fix: align phone and Shodan options and secure FTP defaults --- SpectraScan.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/SpectraScan.py b/SpectraScan.py index c72d502..e2ee4fd 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -6016,7 +6016,7 @@ def _reverse_dns(cls, ips): def _infrastructure(cls, dns_data): ips = dns_data.get("resolved_ips", []) result = {"ips": ips, "reverse_dns": cls._reverse_dns(ips)} - api_key = os.environ.get("SHODAN_API_KEY") + api_key = os.environ.get("SHODAN_API_KEY") or os.environ.get("SHODAN_KEY") if api_key and ips: try: import requests @@ -6233,7 +6233,7 @@ def scan(phone: str, report_manager: ReportManager): report_manager.write("-------------------------------------------------------------------------------") # Uses the external python script from the original codebase - script_path = os.path.join(os.path.dirname(__file__), "modules", "phone_scanner.py") + script_path = os.path.join(os.path.dirname(__file__), "modules", "phone_locator.py") if os.path.exists(script_path): try: result = subprocess.run( @@ -6823,9 +6823,10 @@ def _cli_parser(): parser.add_argument("--brute-force", action="store_true", help="Run bounded SSH/FTP credential validation against an authorized target") parser.add_argument("--service", choices=("ssh", "ftp"), help="Service for --brute-force") parser.add_argument("--port", type=int, help="Service port for --brute-force") - parser.add_argument("--max-attempts", type=int, default=100, help="Maximum passwords from the wordlist (1-1000)") + parser.add_argument("--max-attempts", type=int, default=None, help="Maximum passwords from the wordlist (1-1000; only for --brute-force)") parser.add_argument("--ssh-known-hosts", help="Optional SSH known_hosts file") parser.add_argument("--ssh-host-fingerprint", help="Pin SSH host key using OpenSSH SHA256: fingerprint") + parser.add_argument("--allow-insecure-ftp", action="store_true", help="Allow plaintext FTP credentials (unsafe; requires --yes and --service ftp)") parser.add_argument("--smb", action="store_true", help="Run SMB enumeration against --target") parser.add_argument("--ldap", action="store_true", help="Run LDAP enumeration against --target") parser.add_argument("--dns-zone", action="store_true", help="Check DNS zone transfer against --domain") @@ -6893,12 +6894,16 @@ def main(argv: Optional[List[str]] = None): parser.error("--brute-force requires explicit confirmation with --yes") if not 1 <= args.port <= 65535: parser.error("--port must be between 1 and 65535") + if args.max_attempts is None: + args.max_attempts = 100 if not 1 <= args.max_attempts <= 1000: parser.error("--max-attempts must be between 1 and 1000") if args.service != "ssh" and args.ssh_host_fingerprint: parser.error("--ssh-host-fingerprint is only valid with --service ssh") - elif args.service or args.port is not None or args.ssh_known_hosts or args.ssh_host_fingerprint: - parser.error("--service, --port, and SSH host-key options require --brute-force") + if args.allow_insecure_ftp and args.service != "ftp": + parser.error("--allow-insecure-ftp requires --service ftp") + elif args.service or args.port is not None or args.ssh_known_hosts or args.ssh_host_fingerprint or args.allow_insecure_ftp or args.max_attempts is not None: + parser.error("--service, --port, SSH host-key options, --allow-insecure-ftp, and --max-attempts require --brute-force") if args.email and not EMAIL_REGEX.fullmatch(args.email): parser.error("--email must be a valid email address") if args.phone and detect_target_type(args.phone).get("type") != "phone": @@ -6998,6 +7003,7 @@ def finish_report(report): known_hosts_path=args.ssh_known_hosts, expected_host_fingerprint=args.ssh_host_fingerprint, max_passwords=args.max_attempts, + allow_insecure_ftp=args.allow_insecure_ftp, ) modules["credential_validation"] = { "target": resolved_ip, "port": args.port, "service": args.service, From e798ab62270d7542c50f09ea5952d014e309d52a Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:18:35 +0330 Subject: [PATCH 33/46] fix: use the documented Numverify API endpoint --- modules/phone_locator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/phone_locator.py b/modules/phone_locator.py index 38d9d6b..46dc85f 100644 --- a/modules/phone_locator.py +++ b/modules/phone_locator.py @@ -35,7 +35,7 @@ def main(): try: output = requests.get( - "https://api.apilayer.net/api/validate", + "https://apilayer.net/api/validate", params={ "access_key": config.key, "number": number, From 70e928f940abe06e2c240a5a60615f5f7041c058 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:18:38 +0330 Subject: [PATCH 34/46] fix: reject incompatible CLI combinations explicitly --- SpectraScan.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/SpectraScan.py b/SpectraScan.py index e2ee4fd..f374a44 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -6877,6 +6877,32 @@ def main(argv: Optional[List[str]] = None): if argv: parser = _cli_parser() args = parser.parse_args(argv) + + def option_was_provided(*names): + return any(token in argv for token in names) + + port_options = ( + option_was_provided("-p", "--ports", "-T", "--timing", "--timeout", + "--async-scan", "--async-concurrency", "--async-timeout", + "--scan-type", "--vuln-scan", "--os-detect", "--web-enum", + "--max-targets", "--exclude") + ) + non_port_target = any((args.domain, args.ip, args.email, args.phone, args.image)) + module_target = any((args.smb, args.ldap, args.brute_force)) + if non_port_target and port_options: + parser.error("port-scan options cannot be combined with domain, IP, email, phone, or image modes") + if module_target and port_options: + parser.error("port-scan options cannot be combined with SMB/LDAP/credential-validation modes") + if args.web_enum and not args.target: + parser.error("--web-enum requires -t/--target") + if args.web_enum and args.brute_force: + parser.error("--web-enum and --brute-force must run separately") + if args.vuln_scan and args.async_scan: + parser.error("--vuln-scan is not supported by --async-scan yet") + if args.vuln_scan and args.scan_type == "udp": + parser.error("--vuln-scan is not supported by UDP probes") + if args.wordlist and not (args.web_enum or args.brute_force): + parser.error("--wordlist requires --web-enum or --brute-force") if not any((args.target, args.domain, args.ip, args.email, args.phone, args.image)): parser.error("choose one target using -t/--target, -d/--domain, -i/--ip, -e/--email, --phone, or --image") if args.shodan and not args.ip: @@ -7052,6 +7078,8 @@ def finish_report(report): "timing": "custom", "ports": ports, "decoys": [], "check_vulns": args.vuln_scan, "rate_limit": 0, } + if is_network and (args.web_enum or args.os_detect): + parser.error("--web-enum and --os-detect require a single host target, not a CIDR network") if is_network: if network.version != 4: parser.error("CIDR port sweeps currently support IPv4 only; use a single IPv6 target") From eb2c4498fb8e19997920ebb6b734b6b38bb6f489 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:18:41 +0330 Subject: [PATCH 35/46] docs: document supported CLI modules and safer credential mode --- README.md | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f9fe37b..4726015 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,22 @@ python SpectraScan.py -t scanme.nmap.org -p 1-1000 -T T3 ## 🚀 Usage +### One-shot OSINT and protocol modules + +The one-shot CLI now accepts the supported README options and rejects flags that do not apply to the selected mode: + +```bash +python SpectraScan.py -e target@example.com -o email.json +python SpectraScan.py --phone "+14155552671" -o phone.json +python SpectraScan.py --image ./photo.jpg -o image.json +python SpectraScan.py -d example.com --links --dns-zone -o domain.json +python SpectraScan.py -i 8.8.8.8 --shodan -o ip.json +python SpectraScan.py -t 10.0.0.5 --smb -o smb.json +python SpectraScan.py -t 10.0.0.5 --ldap -o ldap.json +``` + +Credential-validation mode requires an explicit target, service, port, bounded password limit and `--yes` acknowledgement. SSH checks require trusted host keys or an optional SHA256 fingerprint pin. FTPS is the default for FTP service checks; plaintext FTP is available only with explicit `--allow-insecure-ftp`. Configure `NUMVERIFY_KEY` and `SHODAN_API_KEY` (or `SHODAN_KEY`) through environment variables. Never commit API keys to the repository. + ### 🔍 Basic Reconnaissance ```bash @@ -476,7 +492,7 @@ python SpectraScan.py -t 10.0.0.5 --scan-type udp -p 53,161,514 python SpectraScan.py -e target@example.com # Domain intelligence + link sniffing -python SpectraScan.py -d targetdomain.com -l +python SpectraScan.py -d targetdomain.com --links # IP intelligence + Shodan python SpectraScan.py --ip 8.8.8.8 --shodan @@ -495,7 +511,11 @@ python SpectraScan.py --image ./photo.jpg python SpectraScan.py -t target.com --vuln-scan # SSH / FTP credential testing -python SpectraScan.py -t 10.0.0.5 --brute-force --wordlist ./passwords.txt +python SpectraScan.py -t 10.0.0.5 --brute-force --service ssh --port 22 --wordlist ./passwords.txt --max-attempts 100 --yes + +# FTPS is used by default; plaintext FTP requires explicit opt-in: +python SpectraScan.py -t 10.0.0.5 --brute-force --service ftp --port 21 --wordlist ./passwords.txt --yes +# For a legacy plaintext FTP service only, add --allow-insecure-ftp # Web directory enumeration python SpectraScan.py -t example.com --web-enum --wordlist ./dirb_list.txt @@ -683,7 +703,7 @@ SpectraScan/ | `TOR_PORT` | `9050` | Tor SOCKS5 port | | `SPECTRASCAN_HOME` | `~/.local/share/SpectraScan` | Report directory | | `NUMVERIFY_KEY` | — | NumVerify API key | -| `SHODAN_KEY` | — | Shodan API key | +| `SHODAN_API_KEY` / `SHODAN_KEY` | — | Shodan API key | | `NVD_API_KEY` | — | NVD API key | | `EMAILREP_KEY` | — | emailrep.io key | | `HTTP_PROXY` | — | Global outbound HTTP proxy | From 05d832891f8438fde312b4de9dda9247815a6f4c Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:19:29 +0330 Subject: [PATCH 36/46] fix: repair phone locator syntax and usage message --- modules/phone_locator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/phone_locator.py b/modules/phone_locator.py index 46dc85f..e033356 100644 --- a/modules/phone_locator.py +++ b/modules/phone_locator.py @@ -22,7 +22,7 @@ class config: def main(): if len(sys.argv) != 2: - print(f"Usage: python {sys.argv} <phone_number>") + print(f"Usage: python {sys.argv[0]} <phone_number>") sys.exit(1) number = sys.argv[1].strip() @@ -57,7 +57,7 @@ def main(): print(f" - Getting Country Name [{'OK' if country_name else 'FAILED'}]") print(f" - Getting Location [{'OK' if location else 'FAILED'}]") print(f" - Getting Carrier [{'OK' if carrier else 'FAILED'}]") - print(f" - Getting Device [{'OK' if line_type else 'FAILED'}] + print(f" - Getting Device [{'OK' if line_type else 'FAILED'}]") print(f"\n{color.YELLOW}[+] Information Output{color.END}") print("--------------------------------------") From e2f877ebd67469d5624475b0e48f13708a21b16e Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:21:21 +0330 Subject: [PATCH 37/46] fix: bound legacy port and ARP scan resource usage --- SpectraScan.py | 88 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 26 deletions(-) diff --git a/SpectraScan.py b/SpectraScan.py index f374a44..47771ad 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -4979,31 +4979,33 @@ class ARPScanner: def scan(network: str) -> List[Dict]: hosts = [] try: - # Determine the network prefix (e.g., "192.168.1.") for membership testing try: - net = ipaddress.ip_network(network, strict=False) - net_hosts = {str(ip) for ip in net.hosts()} + network_obj = ipaddress.ip_network(network, strict=False) except ValueError: - net_hosts = None + network_obj = None cmd = ["arp", "-a"] if sys.platform == "win32" else ["arp", "-a", "-n"] - output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode() - for line in output.split("\n"): + output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode(errors="replace") + for line in output.splitlines(): match = re.search( r"(\d+\.\d+\.\d+\.\d+)\s+([0-9a-f:-]+)\s+(\w+)", line, re.IGNORECASE, ) - if match: - ip, mac, iface = match.groups() - if net_hosts is None: - if ip.startswith(".".join(network.split(".")[:3]) + "."): - hosts.append({"ip": ip, "mac": mac, "interface": iface}) - else: - if ip in net_hosts: - hosts.append({"ip": ip, "mac": mac, "interface": iface}) - except Exception as e: - print(f"{RED}[-] ARP scan error: {e}{RESET}") + if not match: + continue + ip, mac, iface = match.groups() + if network_obj is not None: + try: + if ipaddress.ip_address(ip) not in network_obj: + continue + except ValueError: + continue + elif not ip.startswith(".".join(network.split(".")[:3]) + "."): + continue + hosts.append({"ip": ip, "mac": mac, "interface": iface}) + except Exception as exc: + print(f"{RED}[-] ARP scan error: {type(exc).__name__}{RESET}") return hosts # ============== Traceroute ============== @@ -5045,6 +5047,7 @@ class PortScanner: "vulnerabilities", "start_time", "rate_limiter", + "result_lock", ) def __init__(self, target: str, **kwargs): @@ -5062,10 +5065,13 @@ def __init__(self, target: str, **kwargs): self.vulnerabilities = [] self.start_time = None self.rate_limiter = RateLimiter(self.rate_limit) if self.rate_limit else None + self.result_lock = threading.Lock() if self.timing in TIMING_PROFILES: profile = TIMING_PROFILES[self.timing] self.threads = profile["threads"] self.timeout = profile["timeout"] + # Keep total worker creation bounded even under the most aggressive profile. + self.threads = max(1, min(int(self.threads), 128)) def initialize(self): print(f"\n{BOLD}{CYAN}{'='*60}") @@ -5113,7 +5119,8 @@ def scan_port(self, port: int) -> Dict: result["version_info"] = identify_service(result["banner"]) if self.check_vulns: result["vulnerabilities"] = check_vulnerabilities(port, service_value, result["banner"]) - self.vulnerabilities.extend(result["vulnerabilities"]) + with self.result_lock: + self.vulnerabilities.extend(result["vulnerabilities"]) return result result = { "port": port, "protocol": "tcp", "state": "closed", @@ -5143,17 +5150,46 @@ def scan_port(self, port: int) -> Dict: return result def scan(self) -> List[Dict]: + """Scan ports with a bounded queue; submit only one worker task per thread.""" self.start_time = time.time() self.results = [] self.vulnerabilities = [] print(f"{CYAN}[*] Spectra is scanning...\n") + sentinel = object() + work_queue = queue.Queue(maxsize=max(2, self.threads * 2)) + + def worker(): + while True: + port = work_queue.get() + try: + if port is sentinel: + return + try: + result = self.scan_port(port) + except Exception as exc: + result = { + "port": port, "protocol": "udp" if self.scan_type == "udp" else "tcp", + "state": "error", "service": COMMON_PORTS.get(port, "unknown"), + "banner": "", "error": type(exc).__name__, "vulnerabilities": [], + } + if result["state"] == "open" or ( + self.scan_type == "udp" and result["state"] == "open|filtered" + ): + with self.result_lock: + self.results.append(result) + self._print_result(result) + finally: + work_queue.task_done() + with concurrent.futures.ThreadPoolExecutor(max_workers=self.threads) as executor: - futures = {executor.submit(self.scan_port, port): port for port in self.ports} - for future in concurrent.futures.as_completed(futures): - result = future.result() - if result["state"] == "open" or (self.scan_type == "udp" and result["state"] == "open|filtered"): - self.results.append(result) - self._print_result(result) + workers = [executor.submit(worker) for _ in range(self.threads)] + for port in self.ports: + work_queue.put(port) + for _ in workers: + work_queue.put(sentinel) + work_queue.join() + for future in workers: + future.result() self.results.sort(key=lambda item: item["port"]) return self.results @@ -6810,7 +6846,7 @@ def _cli_parser(): parser.add_argument("-T", "--timing", choices=sorted(TIMING_PROFILES), default="T3", help="Port-scan timing profile") parser.add_argument("--scan-type", choices=("tcp", "syn", "udp"), default="tcp", help="Select TCP connect, SYN, or UDP probing") parser.add_argument("--timeout", type=float, default=None, help="Override connection timeout in seconds") - parser.add_argument("--threads", type=int, default=100, help="Port worker count (1-512)") + parser.add_argument("--threads", type=int, default=100, help="Port worker count (1-128)") parser.add_argument("--async-scan", action="store_true", help="Use the bounded asyncio TCP scanner") parser.add_argument("--async-concurrency", type=int, default=100, help="Async connections (1-512)") parser.add_argument("--async-timeout", type=float, default=1.0, help="Async connection timeout (0-120 seconds)") @@ -6936,8 +6972,8 @@ def option_was_provided(*names): parser.error("--phone must be a phone number in a recognized format") if not 1 <= args.max_targets <= 65536: parser.error("--max-targets must be between 1 and 65536") - if not 1 <= args.threads <= 512: - parser.error("--threads must be between 1 and 512") + if not 1 <= args.threads <= 128: + parser.error("--threads must be between 1 and 128") if args.timeout is not None and (not math.isfinite(args.timeout) or not 0 < args.timeout <= 120): parser.error("--timeout must be greater than 0 and at most 120 seconds") if args.async_scan and args.scan_type != "tcp": From dd9c82c52983898180bd729e7f43b857ce43de33 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:21:24 +0330 Subject: [PATCH 38/46] test: cover bounded legacy port-scan scheduling --- tests/test_scan_modes.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_scan_modes.py b/tests/test_scan_modes.py index 78fe9ff..af35d6e 100644 --- a/tests/test_scan_modes.py +++ b/tests/test_scan_modes.py @@ -33,6 +33,19 @@ def test_syn_option_uses_syn_scanner(self): self.assertEqual(result["protocol"], "tcp") self.assertEqual(result["state"], "closed") + def test_legacy_scanner_processes_ports_with_bounded_worker_pool(self): + scanner = SpectraScan.PortScanner( + "127.0.0.1", ports=list(range(1, 501)), timing="custom", threads=4 + ) + calls = [] + def fake_probe(port): + calls.append(port) + return {"port": port, "protocol": "tcp", "state": "closed", "service": "unknown", "banner": "", "vulnerabilities": []} + with patch.object(scanner, "scan_port", side_effect=fake_probe): + results = scanner.scan() + self.assertEqual(len(calls), 500) + self.assertEqual(results, []) + def test_syn_scan_does_not_silently_fall_back_to_tcp_connect(self): scanner = SpectraScan.SYNScan() scanner.is_admin = False From 979cf70eb187751acd3cecc0d639e628e699414e Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:22:52 +0330 Subject: [PATCH 39/46] test: patch slotted scanner method at class level --- tests/test_scan_modes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_scan_modes.py b/tests/test_scan_modes.py index af35d6e..6052b95 100644 --- a/tests/test_scan_modes.py +++ b/tests/test_scan_modes.py @@ -41,7 +41,7 @@ def test_legacy_scanner_processes_ports_with_bounded_worker_pool(self): def fake_probe(port): calls.append(port) return {"port": port, "protocol": "tcp", "state": "closed", "service": "unknown", "banner": "", "vulnerabilities": []} - with patch.object(scanner, "scan_port", side_effect=fake_probe): + with patch.object(SpectraScan.PortScanner, "scan_port", fake_probe): results = scanner.scan() self.assertEqual(len(calls), 500) self.assertEqual(results, []) From 0d7b82fc07f2f38d149f14df4daef1b9733e3f12 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:23:50 +0330 Subject: [PATCH 40/46] fix: make timing profiles control worker count --- SpectraScan.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/SpectraScan.py b/SpectraScan.py index 47771ad..aae36e4 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -6846,7 +6846,7 @@ def _cli_parser(): parser.add_argument("-T", "--timing", choices=sorted(TIMING_PROFILES), default="T3", help="Port-scan timing profile") parser.add_argument("--scan-type", choices=("tcp", "syn", "udp"), default="tcp", help="Select TCP connect, SYN, or UDP probing") parser.add_argument("--timeout", type=float, default=None, help="Override connection timeout in seconds") - parser.add_argument("--threads", type=int, default=100, help="Port worker count (1-128)") + parser.add_argument("--threads", type=int, default=None, help="Override timing-profile port worker count (1-128)") parser.add_argument("--async-scan", action="store_true", help="Use the bounded asyncio TCP scanner") parser.add_argument("--async-concurrency", type=int, default=100, help="Async connections (1-512)") parser.add_argument("--async-timeout", type=float, default=1.0, help="Async connection timeout (0-120 seconds)") @@ -6943,8 +6943,8 @@ def option_was_provided(*names): parser.error("choose one target using -t/--target, -d/--domain, -i/--ip, -e/--email, --phone, or --image") if args.shodan and not args.ip: parser.error("--shodan requires -i/--ip") - if args.links and not (args.domain or args.target): - parser.error("--links requires -d/--domain or -t/--target") + if args.links and not args.domain: + parser.error("--links requires -d/--domain") if args.dns_zone and not args.domain: parser.error("--dns-zone requires -d/--domain") if (args.smb or args.ldap or args.brute_force) and not args.target: @@ -6972,7 +6972,7 @@ def option_was_provided(*names): parser.error("--phone must be a phone number in a recognized format") if not 1 <= args.max_targets <= 65536: parser.error("--max-targets must be between 1 and 65536") - if not 1 <= args.threads <= 128: + if args.threads is not None and not 1 <= args.threads <= 128: parser.error("--threads must be between 1 and 128") if args.timeout is not None and (not math.isfinite(args.timeout) or not 0 < args.timeout <= 120): parser.error("--timeout must be greater than 0 and at most 120 seconds") @@ -7061,7 +7061,7 @@ def finish_report(report): if args.brute_force: brute = BruteForcer( resolved_ip, args.port, args.service, args.wordlist, - max_threads=min(args.threads, 32), + max_threads=min(args.threads or 10, 32), known_hosts_path=args.ssh_known_hosts, expected_host_fingerprint=args.ssh_host_fingerprint, max_passwords=args.max_attempts, @@ -7109,7 +7109,7 @@ def finish_report(report): scan_kwargs = { "timeout": args.timeout if args.timeout is not None else TIMING_PROFILES[args.timing]["timeout"], - "threads": args.threads, + "threads": args.threads if args.threads is not None else TIMING_PROFILES[args.timing]["threads"], "scan_type": args.scan_type, "timing": "custom", "ports": ports, "decoys": [], "check_vulns": args.vuln_scan, "rate_limit": 0, From e64c5faeb365e6e5cf6ddf9dde73e69ac3481c1f Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:23:53 +0330 Subject: [PATCH 41/46] test: fix scanner class-level mock signature --- tests/test_scan_modes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_scan_modes.py b/tests/test_scan_modes.py index 6052b95..bc42cae 100644 --- a/tests/test_scan_modes.py +++ b/tests/test_scan_modes.py @@ -38,7 +38,7 @@ def test_legacy_scanner_processes_ports_with_bounded_worker_pool(self): "127.0.0.1", ports=list(range(1, 501)), timing="custom", threads=4 ) calls = [] - def fake_probe(port): + def fake_probe(_scanner, port): calls.append(port) return {"port": port, "protocol": "tcp", "state": "closed", "service": "unknown", "banner": "", "vulnerabilities": []} with patch.object(SpectraScan.PortScanner, "scan_port", fake_probe): From 329a6c468f4cfd0eb259032768621451a910dc4d Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:26:18 +0330 Subject: [PATCH 42/46] feat: integrate bounded NVD correlation into CLI --- SpectraScan.py | 102 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/SpectraScan.py b/SpectraScan.py index aae36e4..92986c4 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -43,6 +43,7 @@ from modules.report_store import ReportStore, atomic_write_text, atomic_write_json, safe_csv_cell from modules.web_enumerator import WebEnumerator from modules.brute_forcer import BruteForcer +from modules.vuln_scanner import VulnScanner from modules.smb_enum import SMBEnumerator from modules.ldap_enum import LDAPEnumerator from modules.dns_zone import DNSZone @@ -6851,6 +6852,8 @@ def _cli_parser(): parser.add_argument("--async-concurrency", type=int, default=100, help="Async connections (1-512)") parser.add_argument("--async-timeout", type=float, default=1.0, help="Async connection timeout (0-120 seconds)") parser.add_argument("--vuln-scan", action="store_true", help="Enable built-in service/port vulnerability heuristics") + parser.add_argument("--cve-correlate", action="store_true", help="Query NVD for version/CPE correlation on a bounded number of detected services") + parser.add_argument("--max-cve-lookups", type=int, default=3, help="Maximum distinct product/version NVD lookups (1-20)") parser.add_argument("--os-detect", action="store_true", help="Record TTL-based OS fingerprint evidence") parser.add_argument("--web-enum", action="store_true", help="Enumerate web paths using --wordlist") parser.add_argument("--wordlist", help="Wordlist for --web-enum") @@ -6874,6 +6877,89 @@ def _cli_parser(): return parser +def correlate_open_port_cves(open_ports, max_lookups=3, api_key=None): + """Correlate unique detected service versions with NVD; static matches stay potential.""" + if isinstance(max_lookups, bool) or not 1 <= int(max_lookups) <= 20: + raise ValueError("max_lookups must be between 1 and 20") + if api_key is None: + api_key = os.environ.get("NVD_API_KEY") or os.environ.get("NVD_API_KEY".replace("_API", "")) + known_products = ( + (("openssh", "openbsd", "openssh"),), + ) + identity_rules = ( + ("openssh", "openbsd", "openssh"), + ("dropbear", "dropbear_ssh_project", "dropbear_ssh"), + ("nginx", "f5", "nginx"), + ("apache httpd", "apache", "http_server"), + ("apache/", "apache", "http_server"), + ("mariadb", "mariadb", "mariadb"), + ("mysql", "oracle", "mysql"), + ("redis", "redis", "redis"), + ("vsftpd", "vsftpd_project", "vsftpd"), + ("proftpd", "proftpd_project", "proftpd"), + ) + unique = {} + for item in open_ports: + banner = str(item.get("banner") or "") + version_info = item.get("version_info") or {} + version = str(version_info.get("version") or "").strip() + if not version or version.lower() == "unknown": + continue + combined = (banner + " " + str(item.get("service") or "") + " " + str(version_info.get("type") or "")).lower() + identity = next((rule for rule in identity_rules if rule[0] in combined), None) + if identity: + _, vendor, product = identity + else: + service = str(version_info.get("type") or item.get("service") or "").lower() + if not service or service == "unknown": + continue + vendor = "" + product = re.sub(r"[^a-z0-9_.-]+", "_", service).strip("_.-") + if not product: + continue + key = (vendor, product, version) + unique.setdefault(key, []).append(item) + + findings = [] + errors = [] + attempted = 0 + for (vendor, product, version), ports_for_product in list(unique.items())[:int(max_lookups)]: + attempted += 1 + try: + scanner = VulnScanner( + product, version, vendor=vendor, product=product, + api_key=api_key, timeout=8.0, max_records=100, + ) + matches = scanner.check_nvd_api() + for finding in matches: + findings.append({ + **finding, + "vendor": vendor or None, + "product": product, + "detected_version": version, + "ports": sorted({int(item["port"]) for item in ports_for_product if item.get("port") is not None}), + }) + if scanner.errors: + errors.extend({ + "vendor": vendor or None, "product": product, "version": version, **error + } for error in scanner.errors) + except Exception as exc: + errors.append({ + "category": "correlation_error", "vendor": vendor or None, + "product": product, "version": version, "message": type(exc).__name__, + }) + skipped = max(0, len(unique) - attempted) + return { + "source": "NVD", + "queries_attempted": attempted, + "unique_detected_products": len(unique), + "queries_skipped_by_limit": skipped, + "findings": findings, + "errors": errors, + "note": "Version/CPE correlations are static evidence and are not proof that a target is exploitable. Verify patch level and exploit preconditions.", + } + + def _write_cli_report(report, output_path, fmt): fmt = fmt or os.path.splitext(output_path)[1].lstrip(".").lower() if fmt == "json": @@ -6920,7 +7006,7 @@ def option_was_provided(*names): port_options = ( option_was_provided("-p", "--ports", "-T", "--timing", "--timeout", "--async-scan", "--async-concurrency", "--async-timeout", - "--scan-type", "--vuln-scan", "--os-detect", "--web-enum", + "--scan-type", "--vuln-scan", "--cve-correlate", "--max-cve-lookups", "--os-detect", "--web-enum", "--max-targets", "--exclude") ) non_port_target = any((args.domain, args.ip, args.email, args.phone, args.image)) @@ -6935,6 +7021,12 @@ def option_was_provided(*names): parser.error("--web-enum and --brute-force must run separately") if args.vuln_scan and args.async_scan: parser.error("--vuln-scan is not supported by --async-scan yet") + if args.cve_correlate and args.async_scan: + parser.error("--cve-correlate requires legacy service/version detection; remove --async-scan") + if args.cve_correlate and args.max_cve_lookups < 1 or args.max_cve_lookups > 20: + parser.error("--max-cve-lookups must be between 1 and 20") + if args.max_cve_lookups != 3 and not args.cve_correlate: + parser.error("--max-cve-lookups requires --cve-correlate") if args.vuln_scan and args.scan_type == "udp": parser.error("--vuln-scan is not supported by UDP probes") if args.wordlist and not (args.web_enum or args.brute_force): @@ -7114,6 +7206,8 @@ def finish_report(report): "timing": "custom", "ports": ports, "decoys": [], "check_vulns": args.vuln_scan, "rate_limit": 0, } + if is_network and args.cve_correlate: + parser.error("--cve-correlate is restricted to a single host to bound NVD API requests") if is_network and (args.web_enum or args.os_detect): parser.error("--web-enum and --os-detect require a single host target, not a CIDR network") if is_network: @@ -7155,6 +7249,12 @@ def finish_report(report): scanner.resolved_ip = resolved_ip scanner.scan() report = scanner.get_results() + if args.cve_correlate: + report["cve_correlation"] = correlate_open_port_cves( + report.get("open_ports", []), + max_lookups=args.max_cve_lookups, + api_key=os.environ.get("NVD_API_KEY"), + ) if args.os_detect: try: report["os_fingerprint_ttl"] = OSFingerprint.get_ttl(resolved_ip) From 46e503e078050d2033f3b18ba9ba1f299b5bda70 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:26:21 +0330 Subject: [PATCH 43/46] test: cover CLI NVD correlation and query bounds --- tests/test_vuln_scanner.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/test_vuln_scanner.py b/tests/test_vuln_scanner.py index 348c47f..87e2985 100644 --- a/tests/test_vuln_scanner.py +++ b/tests/test_vuln_scanner.py @@ -1,8 +1,10 @@ """Mocked CVE parsing/correlation tests; no live NVD requests.""" import unittest +from unittest import mock from unittest.mock import Mock from modules.vuln_scanner import VulnScanner +import SpectraScan class TestVulnScanner(unittest.TestCase): @@ -51,6 +53,29 @@ def test_keyword_only_match_is_not_confirmed(self): self.assertEqual(classification, "informational") self.assertTrue(evidence) + def test_cli_cve_correlation_uses_vendor_product_version_and_bound(self): + fake = mock.MagicMock() + fake.check_nvd_api.return_value = [{"id": "CVE-2099-1234", "classification": "potential"}] + with mock.patch.object(SpectraScan, "VulnScanner", return_value=fake) as factory: + report = SpectraScan.correlate_open_port_cves([ + {"port": 22, "service": "SSH", "banner": "SSH-2.0-OpenSSH_9.1", + "version_info": {"type": "SSH", "version": "9.1"}}, + {"port": 2222, "service": "SSH", "banner": "SSH-2.0-OpenSSH_9.1", + "version_info": {"type": "SSH", "version": "9.1"}}, + {"port": 80, "service": "HTTP", "banner": "HTTP/1.1 nginx/1.24.0", + "version_info": {"type": "HTTP", "version": "1.24.0"}}, + ], max_lookups=1, api_key="test-key") + factory.assert_called_once() + self.assertEqual(report["queries_attempted"], 1) + self.assertEqual(report["queries_skipped_by_limit"], 1) + self.assertEqual(report["findings"][0]["ports"], [22, 2222]) + self.assertEqual(factory.call_args.kwargs["vendor"], "openbsd") + self.assertEqual(factory.call_args.kwargs["product"], "openssh") + + def test_cli_cve_correlation_limit_is_validated(self): + with self.assertRaises(ValueError): + SpectraScan.correlate_open_port_cves([], max_lookups=21) + def test_rate_limit_returns_structured_error(self): session = Mock() response = Mock() From c09f3e11918d474ebb176d75ea6aab7d05297fa1 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:26:24 +0330 Subject: [PATCH 44/46] docs: document bounded optional CVE correlation --- README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/README.md b/README.md index 4726015..234b4f3 100644 --- a/README.md +++ b/README.md @@ -553,6 +553,17 @@ Available operations: | `7` | Full recon | Depends | | `8` | Back | — | +### Evidence-based CVE correlation + +The optional NVD correlation mode is separate from local port/service heuristics so ordinary scans do not unexpectedly make external API calls: + +```bash +python SpectraScan.py -t 127.0.0.1 -p 22,80,443 --cve-correlate -o findings.json +python SpectraScan.py -t 127.0.0.1 -p 22,80,443 --cve-correlate --max-cve-lookups 5 -o findings.json +``` + +Only distinct detected products with a usable version are queried; at most 3 lookups run by default (maximum 20). Configure `NVD_API_KEY` through the environment. Static CPE/version matches are classified as **potential** unless there is stronger evidence; they are not proof of exploitability. + ### 📊 Reports ```bash From 08c008930bc6e438f4b57cde5acaca67f49d3650 Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:27:29 +0330 Subject: [PATCH 45/46] fix: validate NVD lookup limits and product identifiers --- SpectraScan.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/SpectraScan.py b/SpectraScan.py index 92986c4..68eccdb 100644 --- a/SpectraScan.py +++ b/SpectraScan.py @@ -6882,14 +6882,11 @@ def correlate_open_port_cves(open_ports, max_lookups=3, api_key=None): if isinstance(max_lookups, bool) or not 1 <= int(max_lookups) <= 20: raise ValueError("max_lookups must be between 1 and 20") if api_key is None: - api_key = os.environ.get("NVD_API_KEY") or os.environ.get("NVD_API_KEY".replace("_API", "")) - known_products = ( - (("openssh", "openbsd", "openssh"),), - ) + api_key = os.environ.get("NVD_API_KEY") identity_rules = ( ("openssh", "openbsd", "openssh"), ("dropbear", "dropbear_ssh_project", "dropbear_ssh"), - ("nginx", "f5", "nginx"), + ("nginx", "nginx", "nginx"), ("apache httpd", "apache", "http_server"), ("apache/", "apache", "http_server"), ("mariadb", "mariadb", "mariadb"), @@ -7023,7 +7020,7 @@ def option_was_provided(*names): parser.error("--vuln-scan is not supported by --async-scan yet") if args.cve_correlate and args.async_scan: parser.error("--cve-correlate requires legacy service/version detection; remove --async-scan") - if args.cve_correlate and args.max_cve_lookups < 1 or args.max_cve_lookups > 20: + if not 1 <= args.max_cve_lookups <= 20: parser.error("--max-cve-lookups must be between 1 and 20") if args.max_cve_lookups != 3 and not args.cve_correlate: parser.error("--max-cve-lookups requires --cve-correlate") From 3ec6855e2d45f7b9f31a27f84dc56fc877e995ce Mon Sep 17 00:00:00 2001 From: ItsWanheda <i.wanheda.i.gaming@gmail.com> Date: Thu, 22 Oct 2026 19:27:32 +0330 Subject: [PATCH 46/46] test: clarify potential CVE findings and CLI limits --- tests/test_vuln_scanner.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_vuln_scanner.py b/tests/test_vuln_scanner.py index 87e2985..e79180f 100644 --- a/tests/test_vuln_scanner.py +++ b/tests/test_vuln_scanner.py @@ -31,7 +31,7 @@ def test_missing_cvss_metrics_do_not_crash(self): self.assertEqual(found[0]["severity"], "UNKNOWN") self.assertIn(found[0]["classification"], {"potential", "informational", "confirmed"}) - def test_exact_cpe_version_range_classified_confirmed(self): + def test_exact_cpe_version_range_is_potential_without_live_confirmation(self): scanner = VulnScanner("openssh", "9.1", vendor="openbsd", product="openssh") cve = { "configurations": [{ @@ -72,6 +72,12 @@ def test_cli_cve_correlation_uses_vendor_product_version_and_bound(self): self.assertEqual(factory.call_args.kwargs["vendor"], "openbsd") self.assertEqual(factory.call_args.kwargs["product"], "openssh") + def test_cve_lookup_limit_cli_argument_requires_flag(self): + parser = SpectraScan._cli_parser() + args = parser.parse_args(["-t", "127.0.0.1", "--max-cve-lookups", "5"]) + self.assertEqual(args.max_cve_lookups, 5) + self.assertFalse(args.cve_correlate) + def test_cli_cve_correlation_limit_is_validated(self): with self.assertRaises(ValueError): SpectraScan.correlate_open_port_cves([], max_lookups=21)