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
diff --git a/README.md b/README.md
index c9438df..234b4f3 100644
--- a/README.md
+++ b/README.md
@@ -1,731 +1,762 @@
-
-
-# ๐ต๏ธโโ๏ธ SpectraScan
-
-### Advanced Network Reconnaissance, Port Scanning & OSINT Intelligence Framework
-
-
-
-
-
-
-
-
-
-
-
-
-
- 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` |
-
----
-
+
+
+# ๐ต๏ธโโ๏ธ SpectraScan
+
+### Advanced Network Reconnaissance, Port Scanning & OSINT Intelligence Framework
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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.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
+
+### 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
+# 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 --links
+
+# 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 --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
+
+# 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 | โ |
+
+### 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
+# 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_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 +770,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.
-
----
-
-
-
-### ๐ต๏ธโโ๏ธ SpectraScan
-
-**Recon. Enumerate. Understand.**
-
-Made with โค๏ธ by **ItsWanheda & An0nym0us**
-
-[โฌ Back to top](#-spectrascan)
-
-
+## 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.
+
+---
+
+
+
+### ๐ต๏ธโโ๏ธ SpectraScan
+
+**Recon. Enumerate. Understand.**
+
+Made with โค๏ธ by **ItsWanheda & An0nym0us**
+
+[โฌ Back to top](#-spectrascan)
+
+
diff --git a/SpectraScan.py b/SpectraScan.py
index 71082f1..68eccdb 100644
--- a/SpectraScan.py
+++ b/SpectraScan.py
@@ -1,6932 +1,7290 @@
-"""
-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 threading
+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
-
- | Port | Service | State | Banner |
"""]
-
- 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"| {port_num} | {service} | "
- f"{state} | {banner} |
"
- )
-
- parts.append("""
-
-
-""")
-
- 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")
- 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:
+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.vuln_scanner import VulnScanner
+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"))
+
+# 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 or IP. CIDR networks must use the network-scan path."""
+ target = normalize_target(hostname)
+ if not target:
+ raise ValueError("Target cannot be empty")
+ try:
+ 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:
+ 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:
+ def __init__(self):
+ self.is_admin = os.geteuid() == 0 if hasattr(os, "geteuid") else False
+ if sys.platform == "win32":
try:
- target = normalize_target(args.target)
- resolved_ip = resolve_host(target)
+ 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),
+ )
+ 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:
+ """Perform a real half-open SYN probe; never silently fall back to TCP connect."""
+ if not self.is_admin:
+ return "error"
+ try:
+ 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"
+ if flags & 0x04 or flags & 0x14:
+ return "closed"
+ return "filtered"
+ except ImportError:
+ return "error"
+ except PermissionError:
+ return "error"
+ except OSError:
+ return "error"
+ except Exception:
+ 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:
+ try:
+ network_obj = ipaddress.ip_network(network, strict=False)
+ except ValueError:
+ network_obj = None
+
+ cmd = ["arp", "-a"] if sys.platform == "win32" else ["arp", "-a", "-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 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 ==============
+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",
+ "result_lock",
+ )
+
+ 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
+ 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}")
+ 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"])
+ with self.result_lock:
+ 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]:
+ """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:
+ 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
+
+ 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
| Port | Protocol | State | Service | Banner |
"""]
+ 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"| {html.escape(str(cell))} | " for cell in cells) + "
")
+ parts.append("
")
+ 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")
- 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]")
+ 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") or os.environ.get("SHODAN_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, include_shodan: Optional[bool] = None):
+ 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: 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:
+ 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")
+
+# ============== 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_locator.py")
+ if os.path.exists(script_path):
+ try:
+ result = subprocess.run(
+ [sys.executable, 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}.")
+ # 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 = []
+ 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, **bounded_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",
+ )
+
+ 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"],
+ 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:
- scanner = PortScanner(target)
- scanner.resolved_ip = resolved_ip
+ 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 _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")
+ 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")
+ parser.add_argument("--timeout", type=float, default=None, help="Override connection timeout in seconds")
+ 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)")
+ 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")
+ 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=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")
+ 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 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")
+ identity_rules = (
+ ("openssh", "openbsd", "openssh"),
+ ("dropbear", "dropbear_ssh_project", "dropbear_ssh"),
+ ("nginx", "nginx", "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":
+ 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 ReportSpectraScan 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)
+
+ 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", "--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))
+ 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.cve_correlate and args.async_scan:
+ parser.error("--cve-correlate requires legacy service/version detection; remove --async-scan")
+ 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")
+ 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:
+ parser.error("--shodan requires -i/--ip")
+ 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:
+ 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 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")
+ 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":
+ 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 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")
+ 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.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:
+ 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()
+
+ 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, include_shodan=args.shodan)
+ return finish_report({
+ "module": "IP Intelligence", "target": args.ip,
+ "shodan_requested": args.shodan,
+ "timestamp": datetime.now().isoformat(), "output": manager._buffer,
+ })
+
+ 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 or 10, 32),
+ 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,
+ "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:
+ network = ipaddress.ip_network(target, strict=False)
+ is_network = "/" in target
+ 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:
+ 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 TIMING_PROFILES[args.timing]["timeout"],
+ "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,
+ }
+ 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:
+ 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:
+ resolved_ip = None
+ if args.async_scan:
+ scanner = AsyncPortScanner(
+ target, ports=ports, timeout=args.async_timeout,
+ concurrency=args.async_concurrency,
+ )
+ try:
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__":
+ 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()
+ 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)
+ 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)
+ 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
+
+ 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")
+ return 0
+
+
+if __name__ == "__main__":
main()
\ No newline at end of file
diff --git a/modules/async_scanner.py b/modules/async_scanner.py
index 6135a31..4239b21 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,101 @@ 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}")
+ 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 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:
+ 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 +132,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 +162,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()
diff --git a/modules/brute_forcer.py b/modules/brute_forcer.py
index 6313d2c..287da4c 100644
--- a/modules/brute_forcer.py
+++ b/modules/brute_forcer.py
@@ -1,82 +1,206 @@
+"""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,
+ max_passwords: int = 100,
+ allow_insecure_ftp: bool = False,
+ ) -> 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
+ 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]] = []
+ 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}")
+ words = []
+ with open(self.wordlist_path, "r", encoding="utf-8", errors="ignore") as stream:
+ 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 []
- 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") in {"error", "failed"}:
+ 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:
+ 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):
+ 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]:
+ # 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:
- 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
+ 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"}
+ except (socket.timeout, TimeoutError):
+ return {"status": "error", "category": "timeout"}
+ except OSError:
+ return {"status": "error", "category": "connection_error"}
except Exception:
- return False
-
- def run(self):
+ return {"status": "error", "category": "ftp_error"}
+ finally:
+ try:
+ ftp.quit()
+ except Exception:
+ try:
+ ftp.close()
+ except Exception:
+ pass
+ def run(self) -> list[Dict[str, Any]]:
+ self.results = []
+ self.error_counts.clear()
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)
diff --git a/modules/phone_locator.py b/modules/phone_locator.py
index 66e152c..e033356 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} ")
+ print(f"Usage: python {sys.argv[0]} ")
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://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,15 +53,10 @@ 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"\n{color.YELLOW}[+] Information Output{color.END}")
@@ -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
diff --git a/modules/report_store.py b/modules/report_store.py
new file mode 100644
index 0000000..8953962
--- /dev/null
+++ b/modules/report_store.py
@@ -0,0 +1,110 @@
+"""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 _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._safe_new_path(filename), content)
+
+ def save_json(self, filename: str, data: Any) -> str:
+ return atomic_write_json(self._safe_new_path(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()
diff --git a/modules/vuln_scanner.py b/modules/vuln_scanner.py
index 49a26e4..dca9458 100644
--- a/modules/vuln_scanner.py
+++ b/modules/vuln_scanner.py
@@ -1,33 +1,254 @@
+"""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:
+ 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
+
+
+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]}")
+ # 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
+ 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)
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
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):
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()
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..d66aa26
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,99 @@
+"""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_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(""))
+
+ 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()
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()
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()
diff --git a/tests/test_scan_modes.py b/tests/test_scan_modes.py
new file mode 100644
index 0000000..bc42cae
--- /dev/null
+++ b/tests/test_scan_modes.py
@@ -0,0 +1,59 @@
+"""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_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(_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):
+ 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
+ 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()
diff --git a/tests/test_vuln_scanner.py b/tests/test_vuln_scanner.py
new file mode 100644
index 0000000..e79180f
--- /dev/null
+++ b/tests/test_vuln_scanner.py
@@ -0,0 +1,97 @@
+"""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):
+ 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_is_potential_without_live_confirmation(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, "potential")
+ 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_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_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)
+
+ 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()