progress
This commit is contained in:
parent
8263f5cfa9
commit
0c9cf00a3b
@ -4,11 +4,15 @@
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import List, Optional, Set
|
||||
from .data_structures import Certificate
|
||||
from .config import Config
|
||||
|
||||
# Module logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class CertificateChecker:
|
||||
"""Check certificates using crt.sh."""
|
||||
|
||||
@ -17,6 +21,9 @@ class CertificateChecker:
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.last_request = 0
|
||||
self.query_count = 0
|
||||
|
||||
logger.info("🔐 Certificate checker initialized")
|
||||
|
||||
def _rate_limit(self):
|
||||
"""Apply rate limiting for crt.sh."""
|
||||
@ -25,29 +32,45 @@ class CertificateChecker:
|
||||
min_interval = 1.0 / self.config.CRT_SH_RATE_LIMIT
|
||||
|
||||
if time_since_last < min_interval:
|
||||
time.sleep(min_interval - time_since_last)
|
||||
sleep_time = min_interval - time_since_last
|
||||
logger.debug(f"⏸️ crt.sh rate limiting: sleeping for {sleep_time:.2f}s")
|
||||
time.sleep(sleep_time)
|
||||
|
||||
self.last_request = time.time()
|
||||
self.query_count += 1
|
||||
|
||||
def get_certificates(self, domain: str) -> List[Certificate]:
|
||||
"""Get certificates for a domain from crt.sh."""
|
||||
logger.debug(f"🔐 Getting certificates for domain: {domain}")
|
||||
|
||||
certificates = []
|
||||
|
||||
# Query for the domain
|
||||
certificates.extend(self._query_crt_sh(domain))
|
||||
domain_certs = self._query_crt_sh(domain)
|
||||
certificates.extend(domain_certs)
|
||||
|
||||
# Also query for wildcard certificates
|
||||
certificates.extend(self._query_crt_sh(f"%.{domain}"))
|
||||
wildcard_certs = self._query_crt_sh(f"%.{domain}")
|
||||
certificates.extend(wildcard_certs)
|
||||
|
||||
# Remove duplicates based on certificate ID
|
||||
unique_certs = {cert.id: cert for cert in certificates}
|
||||
return list(unique_certs.values())
|
||||
final_certs = list(unique_certs.values())
|
||||
|
||||
if final_certs:
|
||||
logger.info(f"📜 Found {len(final_certs)} unique certificates for {domain}")
|
||||
else:
|
||||
logger.debug(f"❌ No certificates found for {domain}")
|
||||
|
||||
return final_certs
|
||||
|
||||
def _query_crt_sh(self, query: str) -> List[Certificate]:
|
||||
"""Query crt.sh API with retry logic."""
|
||||
certificates = []
|
||||
self._rate_limit()
|
||||
|
||||
logger.debug(f"📡 Querying crt.sh for: {query}")
|
||||
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
@ -59,52 +82,141 @@ class CertificateChecker:
|
||||
response = requests.get(
|
||||
self.CRT_SH_URL,
|
||||
params=params,
|
||||
timeout=self.config.HTTP_TIMEOUT
|
||||
timeout=self.config.HTTP_TIMEOUT,
|
||||
headers={'User-Agent': 'DNS-Recon-Tool/1.0'}
|
||||
)
|
||||
|
||||
logger.debug(f"📡 crt.sh API response for {query}: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
for cert_data in data:
|
||||
try:
|
||||
certificate = Certificate(
|
||||
id=cert_data.get('id'),
|
||||
issuer=cert_data.get('issuer_name', ''),
|
||||
subject=cert_data.get('name_value', ''),
|
||||
not_before=datetime.fromisoformat(
|
||||
cert_data.get('not_before', '').replace('Z', '+00:00')
|
||||
),
|
||||
not_after=datetime.fromisoformat(
|
||||
cert_data.get('not_after', '').replace('Z', '+00:00')
|
||||
),
|
||||
is_wildcard='*.' in cert_data.get('name_value', '')
|
||||
)
|
||||
certificates.append(certificate)
|
||||
except (ValueError, TypeError):
|
||||
continue # Skip malformed certificate data
|
||||
return certificates # Success, exit retry loop
|
||||
try:
|
||||
data = response.json()
|
||||
logger.debug(f"📊 crt.sh returned {len(data)} certificate entries for {query}")
|
||||
|
||||
for cert_data in data:
|
||||
try:
|
||||
# Parse dates with better error handling
|
||||
not_before = self._parse_date(cert_data.get('not_before'))
|
||||
not_after = self._parse_date(cert_data.get('not_after'))
|
||||
|
||||
if not_before and not_after:
|
||||
certificate = Certificate(
|
||||
id=cert_data.get('id'),
|
||||
issuer=cert_data.get('issuer_name', ''),
|
||||
subject=cert_data.get('name_value', ''),
|
||||
not_before=not_before,
|
||||
not_after=not_after,
|
||||
is_wildcard='*.' in cert_data.get('name_value', '')
|
||||
)
|
||||
certificates.append(certificate)
|
||||
logger.debug(f"✅ Parsed certificate ID {certificate.id} for {query}")
|
||||
else:
|
||||
logger.debug(f"⚠️ Skipped certificate with invalid dates: {cert_data.get('id')}")
|
||||
|
||||
except (ValueError, TypeError, KeyError) as e:
|
||||
logger.debug(f"⚠️ Error parsing certificate data: {e}")
|
||||
continue # Skip malformed certificate data
|
||||
|
||||
logger.info(f"✅ Successfully processed {len(certificates)} certificates from crt.sh for {query}")
|
||||
return certificates # Success, exit retry loop
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"❌ Invalid JSON response from crt.sh for {query}: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2 ** attempt) # Exponential backoff
|
||||
continue
|
||||
return certificates
|
||||
|
||||
elif response.status_code == 429:
|
||||
logger.warning(f"⚠️ crt.sh rate limit exceeded for {query}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(5) # Wait longer for rate limits
|
||||
continue
|
||||
return certificates
|
||||
|
||||
else:
|
||||
logger.warning(f"⚠️ crt.sh HTTP error for {query}: {response.status_code}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2)
|
||||
continue
|
||||
return certificates
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"Error querying crt.sh for {query} (attempt {attempt+1}/{max_retries}): {e}")
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"⏱️ crt.sh query timeout for {query} (attempt {attempt+1}/{max_retries})")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2) # Wait 2 seconds before retrying
|
||||
time.sleep(2)
|
||||
continue
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(f"🌐 crt.sh network error for {query} (attempt {attempt+1}/{max_retries}): {e}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Unexpected error querying crt.sh for {query}: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
# If we get here, all retries failed
|
||||
logger.warning(f"❌ All {max_retries} attempts failed for crt.sh query: {query}")
|
||||
return certificates
|
||||
|
||||
def _parse_date(self, date_str: str) -> Optional[datetime]:
|
||||
"""Parse date string with multiple format support."""
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
# Common date formats from crt.sh
|
||||
date_formats = [
|
||||
'%Y-%m-%dT%H:%M:%S', # ISO format without timezone
|
||||
'%Y-%m-%dT%H:%M:%SZ', # ISO format with Z
|
||||
'%Y-%m-%d %H:%M:%S', # Space separated
|
||||
'%Y-%m-%dT%H:%M:%S.%f', # With microseconds
|
||||
'%Y-%m-%dT%H:%M:%S.%fZ', # With microseconds and Z
|
||||
]
|
||||
|
||||
for fmt in date_formats:
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return certificates # Return what we have after all retries
|
||||
# Try with timezone info
|
||||
try:
|
||||
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
logger.debug(f"⚠️ Could not parse date: {date_str}")
|
||||
return None
|
||||
|
||||
def extract_subdomains_from_certificates(self, certificates: List[Certificate]) -> Set[str]:
|
||||
"""Extract subdomains from certificate subjects."""
|
||||
subdomains = set()
|
||||
|
||||
logger.debug(f"🌿 Extracting subdomains from {len(certificates)} certificates")
|
||||
|
||||
for cert in certificates:
|
||||
# Parse subject field for domain names
|
||||
subjects = cert.subject.split('\n')
|
||||
for subject in subjects:
|
||||
subject = subject.strip()
|
||||
# Certificate subjects can be multi-line with multiple domains
|
||||
subject_lines = cert.subject.split('\n')
|
||||
|
||||
for line in subject_lines:
|
||||
line = line.strip()
|
||||
|
||||
# Skip wildcard domains for recursion
|
||||
if not subject.startswith('*.'):
|
||||
if self._is_valid_domain(subject):
|
||||
subdomains.add(subject.lower())
|
||||
# Skip wildcard domains for recursion (they don't resolve directly)
|
||||
if line.startswith('*.'):
|
||||
logger.debug(f"🌿 Skipping wildcard domain: {line}")
|
||||
continue
|
||||
|
||||
if self._is_valid_domain(line):
|
||||
subdomains.add(line.lower())
|
||||
logger.debug(f"🌿 Found subdomain from certificate: {line}")
|
||||
|
||||
if subdomains:
|
||||
logger.info(f"🌿 Extracted {len(subdomains)} subdomains from certificates")
|
||||
else:
|
||||
logger.debug("❌ No subdomains extracted from certificates")
|
||||
|
||||
return subdomains
|
||||
|
||||
@ -114,9 +226,32 @@ class CertificateChecker:
|
||||
return False
|
||||
|
||||
# Remove common prefixes
|
||||
domain = domain.lower()
|
||||
domain = domain.lower().strip()
|
||||
if domain.startswith('www.'):
|
||||
domain = domain[4:]
|
||||
|
||||
# Basic validation
|
||||
return len(domain) > 0 and len(domain) < 255
|
||||
if len(domain) < 3 or len(domain) > 255:
|
||||
return False
|
||||
|
||||
# Must not be an IP address
|
||||
try:
|
||||
import socket
|
||||
socket.inet_aton(domain)
|
||||
return False # It's an IPv4 address
|
||||
except socket.error:
|
||||
pass
|
||||
|
||||
# Check for reasonable domain structure
|
||||
parts = domain.split('.')
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
|
||||
# Each part should be reasonable
|
||||
for part in parts:
|
||||
if len(part) < 1 or len(part) > 63:
|
||||
return False
|
||||
if not part.replace('-', '').replace('_', '').isalnum():
|
||||
return False
|
||||
|
||||
return True
|
@ -2,6 +2,7 @@
|
||||
"""Configuration settings for the reconnaissance tool."""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional
|
||||
|
||||
@ -17,10 +18,11 @@ class Config:
|
||||
virustotal_key: Optional[str] = None
|
||||
|
||||
# Rate limiting (requests per second)
|
||||
DNS_RATE_LIMIT: float = 10.0
|
||||
# DNS servers are generally quite robust, increased from 10 to 50/s
|
||||
DNS_RATE_LIMIT: float = 50.0
|
||||
CRT_SH_RATE_LIMIT: float = 2.0
|
||||
SHODAN_RATE_LIMIT: float = 0.5
|
||||
VIRUSTOTAL_RATE_LIMIT: float = 0.25
|
||||
SHODAN_RATE_LIMIT: float = 0.5 # Shodan is more restrictive
|
||||
VIRUSTOTAL_RATE_LIMIT: float = 0.25 # VirusTotal is very restrictive
|
||||
|
||||
# Recursive depth
|
||||
max_depth: int = 2
|
||||
@ -29,17 +31,50 @@ class Config:
|
||||
DNS_TIMEOUT: int = 5
|
||||
HTTP_TIMEOUT: int = 20
|
||||
|
||||
# Logging level
|
||||
log_level: str = "INFO"
|
||||
|
||||
def __post_init__(self):
|
||||
if self.DNS_SERVERS is None:
|
||||
self.DNS_SERVERS = ['1.1.1.1', '8.8.8.8', '9.9.9.9']
|
||||
# Use multiple reliable DNS servers
|
||||
self.DNS_SERVERS = [
|
||||
'1.1.1.1', # Cloudflare
|
||||
'8.8.8.8', # Google
|
||||
'9.9.9.9' # Quad9
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def from_args(cls, shodan_key: Optional[str] = None,
|
||||
virustotal_key: Optional[str] = None,
|
||||
max_depth: int = 2) -> 'Config':
|
||||
max_depth: int = 2,
|
||||
log_level: str = "INFO") -> 'Config':
|
||||
"""Create config from command line arguments."""
|
||||
return cls(
|
||||
shodan_key=shodan_key,
|
||||
virustotal_key=virustotal_key,
|
||||
max_depth=max_depth
|
||||
max_depth=max_depth,
|
||||
log_level=log_level.upper()
|
||||
)
|
||||
|
||||
def setup_logging(self, cli_mode: bool = True):
|
||||
"""Set up logging configuration."""
|
||||
log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
|
||||
if cli_mode:
|
||||
# For CLI, use a more readable format
|
||||
log_format = '%(asctime)s [%(levelname)s] %(message)s'
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, self.log_level, logging.INFO),
|
||||
format=log_format,
|
||||
datefmt='%H:%M:%S'
|
||||
)
|
||||
|
||||
# Set specific loggers
|
||||
logging.getLogger('urllib3').setLevel(logging.WARNING) # Reduce HTTP noise
|
||||
logging.getLogger('requests').setLevel(logging.WARNING) # Reduce HTTP noise
|
||||
|
||||
if self.log_level == "DEBUG":
|
||||
logging.getLogger(__name__.split('.')[0]).setLevel(logging.DEBUG)
|
||||
|
||||
return logging.getLogger(__name__)
|
@ -5,6 +5,10 @@ from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Set, Optional, Any
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
# Set up logging for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class DNSRecord:
|
||||
@ -12,6 +16,13 @@ class DNSRecord:
|
||||
record_type: str
|
||||
value: str
|
||||
ttl: Optional[int] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'record_type': self.record_type,
|
||||
'value': self.value,
|
||||
'ttl': self.ttl
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class Certificate:
|
||||
@ -22,6 +33,16 @@ class Certificate:
|
||||
not_before: datetime
|
||||
not_after: datetime
|
||||
is_wildcard: bool = False
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'id': self.id,
|
||||
'issuer': self.issuer,
|
||||
'subject': self.subject,
|
||||
'not_before': self.not_before.isoformat() if self.not_before else None,
|
||||
'not_after': self.not_after.isoformat() if self.not_after else None,
|
||||
'is_wildcard': self.is_wildcard
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class ShodanResult:
|
||||
@ -31,6 +52,15 @@ class ShodanResult:
|
||||
services: Dict[str, Any]
|
||||
organization: Optional[str] = None
|
||||
country: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'ip': self.ip,
|
||||
'ports': self.ports,
|
||||
'services': self.services,
|
||||
'organization': self.organization,
|
||||
'country': self.country
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class VirusTotalResult:
|
||||
@ -40,6 +70,15 @@ class VirusTotalResult:
|
||||
total: int
|
||||
scan_date: datetime
|
||||
permalink: str
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
'resource': self.resource,
|
||||
'positives': self.positives,
|
||||
'total': self.total,
|
||||
'scan_date': self.scan_date.isoformat() if self.scan_date else None,
|
||||
'permalink': self.permalink
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class ReconData:
|
||||
@ -67,12 +106,15 @@ class ReconData:
|
||||
|
||||
def add_hostname(self, hostname: str, depth: int = 0) -> None:
|
||||
"""Add a hostname to the dataset."""
|
||||
self.hostnames.add(hostname.lower())
|
||||
self.depth_map[hostname.lower()] = depth
|
||||
hostname = hostname.lower()
|
||||
self.hostnames.add(hostname)
|
||||
self.depth_map[hostname] = depth
|
||||
logger.info(f"Added hostname: {hostname} (depth: {depth})")
|
||||
|
||||
def add_ip_address(self, ip: str) -> None:
|
||||
"""Add an IP address to the dataset."""
|
||||
self.ip_addresses.add(ip)
|
||||
logger.info(f"Added IP address: {ip}")
|
||||
|
||||
def add_dns_record(self, hostname: str, record: DNSRecord) -> None:
|
||||
"""Add a DNS record for a hostname."""
|
||||
@ -80,6 +122,17 @@ class ReconData:
|
||||
if hostname not in self.dns_records:
|
||||
self.dns_records[hostname] = []
|
||||
self.dns_records[hostname].append(record)
|
||||
logger.debug(f"Added DNS record for {hostname}: {record.record_type} -> {record.value}")
|
||||
|
||||
def add_shodan_result(self, ip: str, result: ShodanResult) -> None:
|
||||
"""Add Shodan result."""
|
||||
self.shodan_results[ip] = result
|
||||
logger.info(f"Added Shodan result for {ip}: {len(result.ports)} ports, org: {result.organization}")
|
||||
|
||||
def add_virustotal_result(self, resource: str, result: VirusTotalResult) -> None:
|
||||
"""Add VirusTotal result."""
|
||||
self.virustotal_results[resource] = result
|
||||
logger.info(f"Added VirusTotal result for {resource}: {result.positives}/{result.total} detections")
|
||||
|
||||
def get_new_subdomains(self, max_depth: int) -> Set[str]:
|
||||
"""Get subdomains that haven't been processed yet and are within depth limit."""
|
||||
@ -90,53 +143,62 @@ class ReconData:
|
||||
new_domains.add(hostname)
|
||||
return new_domains
|
||||
|
||||
def get_stats(self) -> Dict[str, int]:
|
||||
"""Get current statistics."""
|
||||
return {
|
||||
'hostnames': len(self.hostnames),
|
||||
'ip_addresses': len(self.ip_addresses),
|
||||
'dns_records': sum(len(records) for records in self.dns_records.values()),
|
||||
'certificates': sum(len(certs) for certs in self.certificates.values()),
|
||||
'shodan_results': len(self.shodan_results),
|
||||
'virustotal_results': len(self.virustotal_results)
|
||||
}
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Export data as a serializable dictionary."""
|
||||
return {
|
||||
'hostnames': list(self.hostnames),
|
||||
'ip_addresses': list(self.ip_addresses),
|
||||
logger.debug(f"Serializing ReconData with stats: {self.get_stats()}")
|
||||
|
||||
result = {
|
||||
'hostnames': sorted(list(self.hostnames)),
|
||||
'ip_addresses': sorted(list(self.ip_addresses)),
|
||||
'dns_records': {
|
||||
host: [{'type': r.record_type, 'value': r.value, 'ttl': r.ttl}
|
||||
for r in records]
|
||||
host: [record.to_dict() for record in records]
|
||||
for host, records in self.dns_records.items()
|
||||
},
|
||||
'reverse_dns': self.reverse_dns,
|
||||
'reverse_dns': dict(self.reverse_dns),
|
||||
'certificates': {
|
||||
host: [{
|
||||
'id': cert.id,
|
||||
'issuer': cert.issuer,
|
||||
'subject': cert.subject,
|
||||
'not_before': cert.not_before.isoformat(),
|
||||
'not_after': cert.not_after.isoformat(),
|
||||
'is_wildcard': cert.is_wildcard
|
||||
} for cert in certs]
|
||||
host: [cert.to_dict() for cert in certs]
|
||||
for host, certs in self.certificates.items()
|
||||
},
|
||||
'shodan_results': {
|
||||
ip: {
|
||||
'ports': result.ports,
|
||||
'services': result.services,
|
||||
'organization': result.organization,
|
||||
'country': result.country
|
||||
} for ip, result in self.shodan_results.items()
|
||||
ip: result.to_dict() for ip, result in self.shodan_results.items()
|
||||
},
|
||||
'virustotal_results': {
|
||||
resource: {
|
||||
'positives': result.positives,
|
||||
'total': result.total,
|
||||
'scan_date': result.scan_date.isoformat(),
|
||||
'permalink': result.permalink
|
||||
} for resource, result in self.virustotal_results.items()
|
||||
resource: result.to_dict() for resource, result in self.virustotal_results.items()
|
||||
},
|
||||
'depth_map': dict(self.depth_map),
|
||||
'metadata': {
|
||||
'start_time': self.start_time.isoformat(),
|
||||
'start_time': self.start_time.isoformat() if self.start_time else None,
|
||||
'end_time': self.end_time.isoformat() if self.end_time else None,
|
||||
'total_hostnames': len(self.hostnames),
|
||||
'total_ips': len(self.ip_addresses)
|
||||
'stats': self.get_stats()
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(f"Serialized data contains: {len(result['hostnames'])} hostnames, "
|
||||
f"{len(result['ip_addresses'])} IPs, {len(result['shodan_results'])} Shodan results, "
|
||||
f"{len(result['virustotal_results'])} VirusTotal results")
|
||||
|
||||
return result
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Export data as JSON."""
|
||||
# Now uses the to_dict method
|
||||
return json.dumps(self.to_dict(), indent=2, default=str)
|
||||
try:
|
||||
return json.dumps(self.to_dict(), indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to serialize to JSON: {e}")
|
||||
# Return minimal JSON in case of error
|
||||
return json.dumps({
|
||||
'error': str(e),
|
||||
'stats': self.get_stats(),
|
||||
'timestamp': datetime.now().isoformat()
|
||||
}, indent=2)
|
@ -8,9 +8,13 @@ import dns.zone
|
||||
from typing import List, Dict, Optional, Set
|
||||
import socket
|
||||
import time
|
||||
import logging
|
||||
from .data_structures import DNSRecord, ReconData
|
||||
from .config import Config
|
||||
|
||||
# Module logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class DNSResolver:
|
||||
"""DNS resolution and record lookup."""
|
||||
|
||||
@ -23,22 +27,33 @@ class DNSResolver:
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
self.last_request = 0
|
||||
self.query_count = 0
|
||||
|
||||
logger.info(f"🌐 DNS resolver initialized with {len(config.DNS_SERVERS)} servers: {config.DNS_SERVERS}")
|
||||
logger.info(f"⚡ DNS rate limit: {config.DNS_RATE_LIMIT}/s, timeout: {config.DNS_TIMEOUT}s")
|
||||
|
||||
def _rate_limit(self):
|
||||
"""Apply rate limiting."""
|
||||
"""Apply rate limiting - more graceful for DNS servers."""
|
||||
now = time.time()
|
||||
time_since_last = now - self.last_request
|
||||
min_interval = 1.0 / self.config.DNS_RATE_LIMIT
|
||||
|
||||
if time_since_last < min_interval:
|
||||
time.sleep(min_interval - time_since_last)
|
||||
sleep_time = min_interval - time_since_last
|
||||
# Only log if sleep is significant to reduce spam
|
||||
if sleep_time > 0.1:
|
||||
logger.debug(f"⏸️ DNS rate limiting: sleeping for {sleep_time:.2f}s")
|
||||
time.sleep(sleep_time)
|
||||
|
||||
self.last_request = time.time()
|
||||
self.query_count += 1
|
||||
|
||||
def resolve_hostname(self, hostname: str) -> List[str]:
|
||||
"""Resolve hostname to IP addresses."""
|
||||
ips = []
|
||||
|
||||
logger.debug(f"🔍 Resolving hostname: {hostname}")
|
||||
|
||||
for dns_server in self.config.DNS_SERVERS:
|
||||
self._rate_limit()
|
||||
resolver = dns.resolver.Resolver()
|
||||
@ -50,24 +65,45 @@ class DNSResolver:
|
||||
answers = resolver.resolve(hostname, 'A')
|
||||
for answer in answers:
|
||||
ips.append(str(answer))
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug(f"✅ A record for {hostname}: {answer}")
|
||||
except dns.resolver.NXDOMAIN:
|
||||
logger.debug(f"❌ NXDOMAIN for {hostname} A record on {dns_server}")
|
||||
except dns.resolver.NoAnswer:
|
||||
logger.debug(f"⚠️ No A record for {hostname} on {dns_server}")
|
||||
except Exception as e:
|
||||
logger.debug(f"⚠️ Error resolving A record for {hostname} on {dns_server}: {e}")
|
||||
|
||||
try:
|
||||
# Try AAAA records
|
||||
# Try AAAA records (IPv6)
|
||||
answers = resolver.resolve(hostname, 'AAAA')
|
||||
for answer in answers:
|
||||
ips.append(str(answer))
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug(f"✅ AAAA record for {hostname}: {answer}")
|
||||
except dns.resolver.NXDOMAIN:
|
||||
logger.debug(f"❌ NXDOMAIN for {hostname} AAAA record on {dns_server}")
|
||||
except dns.resolver.NoAnswer:
|
||||
logger.debug(f"⚠️ No AAAA record for {hostname} on {dns_server}")
|
||||
except Exception as e:
|
||||
logger.debug(f"⚠️ Error resolving AAAA record for {hostname} on {dns_server}: {e}")
|
||||
|
||||
return list(set(ips)) # Remove duplicates
|
||||
unique_ips = list(set(ips))
|
||||
if unique_ips:
|
||||
logger.info(f"✅ Resolved {hostname} to {len(unique_ips)} unique IPs: {unique_ips}")
|
||||
else:
|
||||
logger.debug(f"❌ No IPs found for {hostname}")
|
||||
|
||||
return unique_ips
|
||||
|
||||
def get_all_dns_records(self, hostname: str) -> List[DNSRecord]:
|
||||
"""Get all DNS records for a hostname."""
|
||||
records = []
|
||||
successful_queries = 0
|
||||
|
||||
logger.debug(f"📋 Getting all DNS records for: {hostname}")
|
||||
|
||||
for record_type in self.RECORD_TYPES:
|
||||
type_found = False
|
||||
|
||||
for dns_server in self.config.DNS_SERVERS:
|
||||
self._rate_limit()
|
||||
resolver = dns.resolver.Resolver()
|
||||
@ -82,50 +118,114 @@ class DNSResolver:
|
||||
value=str(answer),
|
||||
ttl=answers.ttl
|
||||
))
|
||||
except Exception:
|
||||
continue
|
||||
if not type_found:
|
||||
logger.debug(f"✅ Found {record_type} record for {hostname}: {answer}")
|
||||
type_found = True
|
||||
|
||||
if not type_found:
|
||||
successful_queries += 1
|
||||
break # Found records, no need to query other DNS servers for this type
|
||||
|
||||
except dns.resolver.NXDOMAIN:
|
||||
logger.debug(f"❌ NXDOMAIN for {hostname} {record_type} on {dns_server}")
|
||||
break # Domain doesn't exist, no point checking other servers
|
||||
except dns.resolver.NoAnswer:
|
||||
logger.debug(f"⚠️ No {record_type} record for {hostname} on {dns_server}")
|
||||
continue # Try next DNS server
|
||||
except dns.resolver.Timeout:
|
||||
logger.debug(f"⏱️ Timeout for {hostname} {record_type} on {dns_server}")
|
||||
continue # Try next DNS server
|
||||
except Exception as e:
|
||||
logger.debug(f"⚠️ Error querying {record_type} for {hostname} on {dns_server}: {e}")
|
||||
continue # Try next DNS server
|
||||
|
||||
logger.info(f"📋 Found {len(records)} DNS records for {hostname} across {len(set(r.record_type for r in records))} record types")
|
||||
|
||||
# Log query statistics every 100 queries
|
||||
if self.query_count % 100 == 0:
|
||||
logger.info(f"📊 DNS query statistics: {self.query_count} total queries performed")
|
||||
|
||||
return records
|
||||
|
||||
def reverse_dns_lookup(self, ip: str) -> Optional[str]:
|
||||
"""Perform reverse DNS lookup."""
|
||||
logger.debug(f"🔍 Reverse DNS lookup for: {ip}")
|
||||
|
||||
try:
|
||||
self._rate_limit()
|
||||
return socket.gethostbyaddr(ip)[0]
|
||||
except Exception:
|
||||
hostname = socket.gethostbyaddr(ip)[0]
|
||||
logger.info(f"✅ Reverse DNS for {ip}: {hostname}")
|
||||
return hostname
|
||||
except socket.herror:
|
||||
logger.debug(f"❌ No reverse DNS for {ip}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"⚠️ Error in reverse DNS for {ip}: {e}")
|
||||
return None
|
||||
|
||||
def extract_subdomains_from_dns(self, records: List[DNSRecord]) -> Set[str]:
|
||||
"""Extract potential subdomains from DNS records."""
|
||||
subdomains = set()
|
||||
|
||||
logger.debug(f"🌿 Extracting subdomains from {len(records)} DNS records")
|
||||
|
||||
for record in records:
|
||||
value = record.value.lower()
|
||||
|
||||
# Extract from CNAME, NS, and correctly from MX records
|
||||
if record.record_type == 'MX':
|
||||
# MX record values are like: "10 mail.example.com."
|
||||
# We need to extract the hostname part.
|
||||
parts = value.split()
|
||||
if len(parts) == 2:
|
||||
hostname = parts[1].rstrip('.')
|
||||
# Extract from different record types
|
||||
try:
|
||||
if record.record_type == 'MX':
|
||||
# MX record format: "priority hostname"
|
||||
parts = value.split()
|
||||
if len(parts) >= 2:
|
||||
hostname = parts[-1].rstrip('.') # Take the last part (hostname)
|
||||
if self._is_valid_hostname(hostname):
|
||||
subdomains.add(hostname)
|
||||
logger.debug(f"🌿 Found subdomain from MX: {hostname}")
|
||||
|
||||
elif record.record_type in ['CNAME', 'NS']:
|
||||
# Direct hostname records
|
||||
hostname = value.rstrip('.')
|
||||
if self._is_valid_hostname(hostname):
|
||||
subdomains.add(hostname)
|
||||
elif record.record_type in ['CNAME', 'NS']:
|
||||
# These records are just the hostname
|
||||
hostname = value.rstrip('.')
|
||||
if self._is_valid_hostname(hostname):
|
||||
subdomains.add(hostname)
|
||||
|
||||
# Extract from TXT records (sometimes contain domain references)
|
||||
elif record.record_type == 'TXT':
|
||||
# Look for domain-like strings in TXT records
|
||||
parts = value.split()
|
||||
for part in parts:
|
||||
if '.' in part and not part.startswith('http'):
|
||||
clean_part = part.strip('",\'()[]{}')
|
||||
if self._is_valid_hostname(clean_part):
|
||||
subdomains.add(clean_part)
|
||||
logger.debug(f"🌿 Found subdomain from {record.record_type}: {hostname}")
|
||||
|
||||
elif record.record_type == 'TXT':
|
||||
# Search for domain-like strings in TXT records
|
||||
# Common patterns: include:example.com, v=spf1 include:_spf.google.com
|
||||
words = value.replace(',', ' ').replace(';', ' ').split()
|
||||
for word in words:
|
||||
# Look for include: patterns
|
||||
if word.startswith('include:'):
|
||||
hostname = word[8:].rstrip('.')
|
||||
if self._is_valid_hostname(hostname):
|
||||
subdomains.add(hostname)
|
||||
logger.debug(f"🌿 Found subdomain from TXT include: {hostname}")
|
||||
|
||||
# Look for other domain patterns
|
||||
elif '.' in word and not word.startswith('http'):
|
||||
clean_word = word.strip('",\'()[]{}').rstrip('.')
|
||||
if self._is_valid_hostname(clean_word):
|
||||
subdomains.add(clean_word)
|
||||
logger.debug(f"🌿 Found subdomain from TXT: {clean_word}")
|
||||
|
||||
elif record.record_type == 'SRV':
|
||||
# SRV record format: "priority weight port target"
|
||||
parts = value.split()
|
||||
if len(parts) >= 4:
|
||||
hostname = parts[-1].rstrip('.') # Target hostname
|
||||
if self._is_valid_hostname(hostname):
|
||||
subdomains.add(hostname)
|
||||
logger.debug(f"🌿 Found subdomain from SRV: {hostname}")
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"⚠️ Error extracting subdomain from {record.record_type} record '{value}': {e}")
|
||||
continue
|
||||
|
||||
if subdomains:
|
||||
logger.info(f"🌿 Extracted {len(subdomains)} potential subdomains")
|
||||
else:
|
||||
logger.debug("❌ No subdomains extracted from DNS records")
|
||||
|
||||
return subdomains
|
||||
|
||||
@ -138,6 +238,43 @@ class DNSResolver:
|
||||
if '.' not in hostname:
|
||||
return False
|
||||
|
||||
# Basic character check
|
||||
allowed_chars = set('abcdefghijklmnopqrstuvwxyz0123456789.-')
|
||||
return all(c in allowed_chars for c in hostname.lower())
|
||||
# Must not be an IP address
|
||||
if self._looks_like_ip(hostname):
|
||||
return False
|
||||
|
||||
# Basic character check - allow international domains
|
||||
# Remove overly restrictive character filtering
|
||||
if not hostname.replace('-', '').replace('.', '').replace('_', '').isalnum():
|
||||
# Allow some special cases for internationalized domains
|
||||
try:
|
||||
hostname.encode('ascii')
|
||||
except UnicodeEncodeError:
|
||||
return False # Skip non-ASCII for now
|
||||
|
||||
# Must have reasonable length parts
|
||||
parts = hostname.split('.')
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
|
||||
# Each part should be reasonable length
|
||||
for part in parts:
|
||||
if len(part) < 1 or len(part) > 63:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _looks_like_ip(self, text: str) -> bool:
|
||||
"""Check if text looks like an IP address."""
|
||||
try:
|
||||
socket.inet_aton(text)
|
||||
return True
|
||||
except socket.error:
|
||||
pass
|
||||
|
||||
try:
|
||||
socket.inet_pton(socket.AF_INET6, text)
|
||||
return True
|
||||
except socket.error:
|
||||
pass
|
||||
|
||||
return False
|
142
src/main.py
142
src/main.py
@ -4,12 +4,16 @@
|
||||
import click
|
||||
import json
|
||||
import sys
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from .config import Config
|
||||
from .reconnaissance import ReconnaissanceEngine
|
||||
from .report_generator import ReportGenerator
|
||||
from .web_app import create_app
|
||||
|
||||
# Module logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@click.command()
|
||||
@click.argument('target', required=False)
|
||||
@click.option('--web', is_flag=True, help='Start web interface instead of CLI')
|
||||
@ -20,87 +24,171 @@ from .web_app import create_app
|
||||
@click.option('--json-only', is_flag=True, help='Only output JSON')
|
||||
@click.option('--text-only', is_flag=True, help='Only output text report')
|
||||
@click.option('--port', default=5000, help='Port for web interface (default: 5000)')
|
||||
def main(target, web, shodan_key, virustotal_key, max_depth, output, json_only, text_only, port):
|
||||
@click.option('--verbose', '-v', is_flag=True, help='Enable verbose logging (DEBUG level)')
|
||||
@click.option('--quiet', '-q', is_flag=True, help='Quiet mode (WARNING level only)')
|
||||
def main(target, web, shodan_key, virustotal_key, max_depth, output, json_only, text_only, port, verbose, quiet):
|
||||
"""DNS Reconnaissance Tool
|
||||
|
||||
Examples:
|
||||
recon example.com # Scan example.com
|
||||
recon example # Try example.* for all TLDs
|
||||
recon example.com --max-depth 3 # Deeper recursion
|
||||
recon example.com -v # Verbose logging
|
||||
recon --web # Start web interface
|
||||
"""
|
||||
|
||||
# Determine log level
|
||||
if verbose:
|
||||
log_level = "DEBUG"
|
||||
elif quiet:
|
||||
log_level = "WARNING"
|
||||
else:
|
||||
log_level = "INFO"
|
||||
|
||||
# Create configuration and setup logging
|
||||
config = Config.from_args(shodan_key, virustotal_key, max_depth, log_level)
|
||||
config.setup_logging(cli_mode=True)
|
||||
|
||||
if web:
|
||||
# Start web interface
|
||||
app = create_app(Config.from_args(shodan_key, virustotal_key, max_depth))
|
||||
app.run(host='0.0.0.0', port=port, debug=True)
|
||||
logger.info("🌐 Starting web interface...")
|
||||
app = create_app(config)
|
||||
logger.info(f"🚀 Web interface starting on http://0.0.0.0:{port}")
|
||||
app.run(host='0.0.0.0', port=port, debug=False) # Changed debug to False to reduce noise
|
||||
return
|
||||
|
||||
if not target:
|
||||
click.echo("Error: TARGET is required for CLI mode. Use --web for web interface.")
|
||||
click.echo("❌ Error: TARGET is required for CLI mode. Use --web for web interface.")
|
||||
sys.exit(1)
|
||||
|
||||
# Create configuration
|
||||
config = Config.from_args(shodan_key, virustotal_key, max_depth)
|
||||
|
||||
# Initialize reconnaissance engine
|
||||
logger.info("🔧 Initializing reconnaissance engine...")
|
||||
engine = ReconnaissanceEngine(config)
|
||||
|
||||
# Set up progress callback
|
||||
# Set up progress callback for CLI
|
||||
def progress_callback(message, percentage=None):
|
||||
if percentage:
|
||||
if percentage is not None:
|
||||
click.echo(f"[{percentage:3d}%] {message}")
|
||||
else:
|
||||
click.echo(f" {message}")
|
||||
|
||||
engine.set_progress_callback(progress_callback)
|
||||
|
||||
# Run reconnaissance
|
||||
click.echo(f"Starting reconnaissance for: {target}")
|
||||
click.echo(f"Max recursion depth: {max_depth}")
|
||||
# Display startup information
|
||||
click.echo("=" * 60)
|
||||
click.echo("🔍 DNS RECONNAISSANCE TOOL")
|
||||
click.echo("=" * 60)
|
||||
click.echo(f"🎯 Target: {target}")
|
||||
click.echo(f"📊 Max recursion depth: {max_depth}")
|
||||
click.echo(f"🌐 DNS servers: {', '.join(config.DNS_SERVERS[:3])}{'...' if len(config.DNS_SERVERS) > 3 else ''}")
|
||||
click.echo(f"⚡ DNS rate limit: {config.DNS_RATE_LIMIT}/s")
|
||||
|
||||
if shodan_key:
|
||||
click.echo("✓ Shodan integration enabled")
|
||||
click.echo("✅ Shodan integration enabled")
|
||||
logger.info(f"🕵️ Shodan API key provided (ends with: ...{shodan_key[-4:] if len(shodan_key) > 4 else shodan_key})")
|
||||
else:
|
||||
click.echo("⚠️ Shodan integration disabled (no API key)")
|
||||
|
||||
if virustotal_key:
|
||||
click.echo("✓ VirusTotal integration enabled")
|
||||
click.echo("✅ VirusTotal integration enabled")
|
||||
logger.info(f"🛡️ VirusTotal API key provided (ends with: ...{virustotal_key[-4:] if len(virustotal_key) > 4 else virustotal_key})")
|
||||
else:
|
||||
click.echo("⚠️ VirusTotal integration disabled (no API key)")
|
||||
|
||||
click.echo("")
|
||||
|
||||
# Run reconnaissance
|
||||
try:
|
||||
logger.info(f"🚀 Starting reconnaissance for target: {target}")
|
||||
data = engine.run_reconnaissance(target)
|
||||
|
||||
# Display final statistics
|
||||
stats = data.get_stats()
|
||||
click.echo("")
|
||||
click.echo("=" * 60)
|
||||
click.echo("📊 RECONNAISSANCE COMPLETE")
|
||||
click.echo("=" * 60)
|
||||
click.echo(f"🏠 Hostnames discovered: {stats['hostnames']}")
|
||||
click.echo(f"🌐 IP addresses found: {stats['ip_addresses']}")
|
||||
click.echo(f"📋 DNS records collected: {stats['dns_records']}")
|
||||
click.echo(f"📜 Certificates found: {stats['certificates']}")
|
||||
click.echo(f"🕵️ Shodan results: {stats['shodan_results']}")
|
||||
click.echo(f"🛡️ VirusTotal results: {stats['virustotal_results']}")
|
||||
|
||||
# Calculate and display timing
|
||||
if data.end_time and data.start_time:
|
||||
duration = data.end_time - data.start_time
|
||||
click.echo(f"⏱️ Total time: {duration}")
|
||||
|
||||
click.echo("")
|
||||
|
||||
# Generate reports
|
||||
logger.info("📄 Generating reports...")
|
||||
report_gen = ReportGenerator(data)
|
||||
|
||||
if output:
|
||||
# Save to files
|
||||
saved_files = []
|
||||
|
||||
if not text_only:
|
||||
json_file = f"{output}.json"
|
||||
with open(json_file, 'w') as f:
|
||||
f.write(data.to_json())
|
||||
click.echo(f"JSON report saved to: {json_file}")
|
||||
try:
|
||||
json_content = data.to_json()
|
||||
with open(json_file, 'w', encoding='utf-8') as f:
|
||||
f.write(json_content)
|
||||
saved_files.append(json_file)
|
||||
logger.info(f"💾 JSON report saved: {json_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to save JSON report: {e}")
|
||||
|
||||
if not json_only:
|
||||
text_file = f"{output}.txt"
|
||||
with open(text_file, 'w') as f:
|
||||
f.write(report_gen.generate_text_report())
|
||||
click.echo(f"Text report saved to: {text_file}")
|
||||
try:
|
||||
with open(text_file, 'w', encoding='utf-8') as f:
|
||||
f.write(report_gen.generate_text_report())
|
||||
saved_files.append(text_file)
|
||||
logger.info(f"💾 Text report saved: {text_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to save text report: {e}")
|
||||
|
||||
if saved_files:
|
||||
click.echo(f"💾 Reports saved:")
|
||||
for file in saved_files:
|
||||
click.echo(f" 📄 {file}")
|
||||
|
||||
else:
|
||||
# Output to stdout
|
||||
if json_only:
|
||||
click.echo(data.to_json())
|
||||
try:
|
||||
click.echo(data.to_json())
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to generate JSON output: {e}")
|
||||
click.echo(f"Error generating JSON: {e}")
|
||||
elif text_only:
|
||||
click.echo(report_gen.generate_text_report())
|
||||
try:
|
||||
click.echo(report_gen.generate_text_report())
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to generate text report: {e}")
|
||||
click.echo(f"Error generating text report: {e}")
|
||||
else:
|
||||
# Default: show text report
|
||||
click.echo(report_gen.generate_text_report())
|
||||
click.echo(f"\nTo get JSON output, use: --json-only")
|
||||
click.echo(f"To save reports, use: --output filename")
|
||||
try:
|
||||
click.echo(report_gen.generate_text_report())
|
||||
click.echo(f"\n💡 To get JSON output, use: --json-only")
|
||||
click.echo(f"💡 To save reports, use: --output filename")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to generate report: {e}")
|
||||
click.echo(f"Error generating report: {e}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
click.echo("\nReconnaissance interrupted by user.")
|
||||
logger.warning("⚠️ Reconnaissance interrupted by user")
|
||||
click.echo("\n⚠️ Reconnaissance interrupted by user.")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
click.echo(f"Error during reconnaissance: {e}")
|
||||
logger.error(f"❌ Error during reconnaissance: {e}", exc_info=True)
|
||||
click.echo(f"❌ Error during reconnaissance: {e}")
|
||||
if verbose:
|
||||
raise # Re-raise in verbose mode to show full traceback
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
@ -3,6 +3,7 @@
|
||||
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Set, List, Optional
|
||||
from .data_structures import ReconData
|
||||
@ -13,12 +14,14 @@ from .shodan_client import ShodanClient
|
||||
from .virustotal_client import VirusTotalClient
|
||||
from .tld_fetcher import TLDFetcher
|
||||
|
||||
# Set up logging for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ReconnaissanceEngine:
|
||||
"""Main reconnaissance engine."""
|
||||
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
# self.data = ReconData() # <-- REMOVED FROM HERE
|
||||
|
||||
# Initialize clients
|
||||
self.dns_resolver = DNSResolver(config)
|
||||
@ -29,10 +32,16 @@ class ReconnaissanceEngine:
|
||||
self.shodan_client = None
|
||||
if config.shodan_key:
|
||||
self.shodan_client = ShodanClient(config.shodan_key, config)
|
||||
logger.info("✅ Shodan client initialized")
|
||||
else:
|
||||
logger.info("⚠️ Shodan API key not provided, skipping Shodan integration")
|
||||
|
||||
self.virustotal_client = None
|
||||
if config.virustotal_key:
|
||||
self.virustotal_client = VirusTotalClient(config.virustotal_key, config)
|
||||
logger.info("✅ VirusTotal client initialized")
|
||||
else:
|
||||
logger.info("⚠️ VirusTotal API key not provided, skipping VirusTotal integration")
|
||||
|
||||
# Progress tracking
|
||||
self.progress_callback = None
|
||||
@ -44,6 +53,7 @@ class ReconnaissanceEngine:
|
||||
|
||||
def _update_progress(self, message: str, percentage: int = None):
|
||||
"""Update progress if callback is set."""
|
||||
logger.info(f"Progress: {message} ({percentage}%)" if percentage else f"Progress: {message}")
|
||||
if self.progress_callback:
|
||||
self.progress_callback(message, percentage)
|
||||
|
||||
@ -52,15 +62,22 @@ class ReconnaissanceEngine:
|
||||
self.data = ReconData()
|
||||
self.data.start_time = datetime.now()
|
||||
|
||||
logger.info(f"🚀 Starting reconnaissance for target: {target}")
|
||||
logger.info(f"📊 Configuration: max_depth={self.config.max_depth}, "
|
||||
f"DNS_rate={self.config.DNS_RATE_LIMIT}/s")
|
||||
|
||||
try:
|
||||
# Determine if target is hostname.tld or just hostname
|
||||
if '.' in target:
|
||||
logger.info(f"🎯 Target '{target}' appears to be a full domain name")
|
||||
self._update_progress(f"Starting reconnaissance for {target}", 0)
|
||||
self.data.add_hostname(target, 0)
|
||||
initial_targets = {target}
|
||||
else:
|
||||
logger.info(f"🔍 Target '{target}' appears to be a hostname, expanding to all TLDs")
|
||||
self._update_progress(f"Expanding {target} to all TLDs", 5)
|
||||
initial_targets = self._expand_hostname_to_tlds(target)
|
||||
logger.info(f"📋 Found {len(initial_targets)} valid domains after TLD expansion")
|
||||
|
||||
self._update_progress("Resolving initial targets", 10)
|
||||
|
||||
@ -71,24 +88,40 @@ class ReconnaissanceEngine:
|
||||
self._update_progress("Performing external service lookups", 90)
|
||||
self._perform_external_lookups()
|
||||
|
||||
# Log final statistics
|
||||
stats = self.data.get_stats()
|
||||
logger.info(f"📈 Final statistics: {stats}")
|
||||
|
||||
self._update_progress("Reconnaissance complete", 100)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error during reconnaissance: {e}", exc_info=True)
|
||||
raise
|
||||
finally:
|
||||
self.data.end_time = datetime.now()
|
||||
duration = self.data.end_time - self.data.start_time
|
||||
logger.info(f"⏱️ Total reconnaissance time: {duration}")
|
||||
|
||||
return self.data
|
||||
|
||||
def _expand_hostname_to_tlds(self, hostname: str) -> Set[str]:
|
||||
"""Expand hostname to all possible TLDs."""
|
||||
logger.info(f"🌐 Fetching TLD list for hostname expansion")
|
||||
tlds = self.tld_fetcher.get_tlds()
|
||||
logger.info(f"📝 Testing against {len(tlds)} TLDs")
|
||||
|
||||
targets = set()
|
||||
tested_count = 0
|
||||
|
||||
for i, tld in enumerate(tlds):
|
||||
full_hostname = f"{hostname}.{tld}"
|
||||
|
||||
# Quick check if domain resolves
|
||||
ips = self.dns_resolver.resolve_hostname(full_hostname)
|
||||
tested_count += 1
|
||||
|
||||
if ips:
|
||||
logger.info(f"✅ Found valid domain: {full_hostname} -> {ips}")
|
||||
self.data.add_hostname(full_hostname, 0)
|
||||
targets.add(full_hostname)
|
||||
for ip in ips:
|
||||
@ -99,6 +132,7 @@ class ReconnaissanceEngine:
|
||||
progress = 5 + int((i / len(tlds)) * 5) # 5-10% range
|
||||
self._update_progress(f"Checked {i}/{len(tlds)} TLDs, found {len(targets)} valid domains", progress)
|
||||
|
||||
logger.info(f"🎯 TLD expansion complete: tested {tested_count} TLDs, found {len(targets)} valid domains")
|
||||
return targets
|
||||
|
||||
def _process_targets_recursively(self, targets: Set[str]):
|
||||
@ -106,28 +140,40 @@ class ReconnaissanceEngine:
|
||||
current_depth = 0
|
||||
|
||||
while current_depth <= self.config.max_depth and targets:
|
||||
self._update_progress(f"Processing depth {current_depth}", 15 + (current_depth * 25))
|
||||
logger.info(f"🔄 Processing depth {current_depth} with {len(targets)} targets")
|
||||
self._update_progress(f"Processing depth {current_depth} ({len(targets)} targets)", 15 + (current_depth * 25))
|
||||
|
||||
new_targets = set()
|
||||
|
||||
for target in targets:
|
||||
logger.debug(f"🔍 Processing target: {target}")
|
||||
|
||||
# DNS resolution and record gathering
|
||||
self._process_single_target(target, current_depth)
|
||||
|
||||
# Extract new subdomains
|
||||
if current_depth < self.config.max_depth:
|
||||
new_subdomains = self._extract_new_subdomains(target)
|
||||
logger.debug(f"🌿 Found {len(new_subdomains)} new subdomains from {target}")
|
||||
|
||||
for subdomain in new_subdomains:
|
||||
self.data.add_hostname(subdomain, current_depth + 1)
|
||||
new_targets.add(subdomain)
|
||||
|
||||
logger.info(f"📊 Depth {current_depth} complete. Found {len(new_targets)} new targets for next depth")
|
||||
targets = new_targets
|
||||
current_depth += 1
|
||||
|
||||
logger.info(f"🏁 Recursive processing complete after {current_depth} levels")
|
||||
|
||||
def _process_single_target(self, hostname: str, depth: int):
|
||||
"""Process a single target hostname."""
|
||||
logger.debug(f"🎯 Processing single target: {hostname} at depth {depth}")
|
||||
|
||||
# Get all DNS records
|
||||
dns_records = self.dns_resolver.get_all_dns_records(hostname)
|
||||
logger.debug(f"📋 Found {len(dns_records)} DNS records for {hostname}")
|
||||
|
||||
for record in dns_records:
|
||||
self.data.add_dns_record(hostname, record)
|
||||
|
||||
@ -136,9 +182,13 @@ class ReconnaissanceEngine:
|
||||
self.data.add_ip_address(record.value)
|
||||
|
||||
# Get certificates
|
||||
logger.debug(f"🔐 Checking certificates for {hostname}")
|
||||
certificates = self.cert_checker.get_certificates(hostname)
|
||||
if certificates:
|
||||
self.data.certificates[hostname] = certificates
|
||||
logger.info(f"📜 Found {len(certificates)} certificates for {hostname}")
|
||||
else:
|
||||
logger.debug(f"❌ No certificates found for {hostname}")
|
||||
|
||||
def _extract_new_subdomains(self, hostname: str) -> Set[str]:
|
||||
"""Extract new subdomains from DNS records and certificates."""
|
||||
@ -150,6 +200,7 @@ class ReconnaissanceEngine:
|
||||
self.data.dns_records[hostname]
|
||||
)
|
||||
new_subdomains.update(dns_subdomains)
|
||||
logger.debug(f"🌐 Extracted {len(dns_subdomains)} subdomains from DNS records of {hostname}")
|
||||
|
||||
# From certificates
|
||||
if hostname in self.data.certificates:
|
||||
@ -157,35 +208,94 @@ class ReconnaissanceEngine:
|
||||
self.data.certificates[hostname]
|
||||
)
|
||||
new_subdomains.update(cert_subdomains)
|
||||
logger.debug(f"🔐 Extracted {len(cert_subdomains)} subdomains from certificates of {hostname}")
|
||||
|
||||
# Filter out already known hostnames
|
||||
return new_subdomains - self.data.hostnames
|
||||
filtered_subdomains = new_subdomains - self.data.hostnames
|
||||
logger.debug(f"🆕 {len(filtered_subdomains)} new subdomains after filtering")
|
||||
|
||||
return filtered_subdomains
|
||||
|
||||
def _perform_external_lookups(self):
|
||||
"""Perform Shodan and VirusTotal lookups."""
|
||||
logger.info(f"🔍 Starting external lookups for {len(self.data.ip_addresses)} IPs and {len(self.data.hostnames)} hostnames")
|
||||
|
||||
# Reverse DNS for all IPs
|
||||
logger.info("🔄 Performing reverse DNS lookups")
|
||||
reverse_dns_count = 0
|
||||
for ip in self.data.ip_addresses:
|
||||
reverse = self.dns_resolver.reverse_dns_lookup(ip)
|
||||
if reverse:
|
||||
self.data.reverse_dns[ip] = reverse
|
||||
reverse_dns_count += 1
|
||||
logger.debug(f"🔙 Reverse DNS for {ip}: {reverse}")
|
||||
|
||||
logger.info(f"✅ Completed reverse DNS: {reverse_dns_count}/{len(self.data.ip_addresses)} successful")
|
||||
|
||||
# Shodan lookups
|
||||
if self.shodan_client:
|
||||
logger.info(f"🕵️ Starting Shodan lookups for {len(self.data.ip_addresses)} IPs")
|
||||
shodan_success_count = 0
|
||||
|
||||
for ip in self.data.ip_addresses:
|
||||
result = self.shodan_client.lookup_ip(ip)
|
||||
if result:
|
||||
self.data.shodan_results[ip] = result
|
||||
try:
|
||||
logger.debug(f"🔍 Querying Shodan for IP: {ip}")
|
||||
result = self.shodan_client.lookup_ip(ip)
|
||||
if result:
|
||||
self.data.add_shodan_result(ip, result)
|
||||
shodan_success_count += 1
|
||||
logger.info(f"✅ Shodan result for {ip}: {len(result.ports)} ports")
|
||||
else:
|
||||
logger.debug(f"❌ No Shodan data for {ip}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Error querying Shodan for {ip}: {e}")
|
||||
|
||||
logger.info(f"✅ Shodan lookups complete: {shodan_success_count}/{len(self.data.ip_addresses)} successful")
|
||||
else:
|
||||
logger.info("⚠️ Skipping Shodan lookups (no API key)")
|
||||
|
||||
# VirusTotal lookups
|
||||
if self.virustotal_client:
|
||||
total_resources = len(self.data.ip_addresses) + len(self.data.hostnames)
|
||||
logger.info(f"🛡️ Starting VirusTotal lookups for {total_resources} resources")
|
||||
vt_success_count = 0
|
||||
|
||||
# Check IPs
|
||||
for ip in self.data.ip_addresses:
|
||||
result = self.virustotal_client.lookup_ip(ip)
|
||||
if result:
|
||||
self.data.virustotal_results[ip] = result
|
||||
try:
|
||||
logger.debug(f"🔍 Querying VirusTotal for IP: {ip}")
|
||||
result = self.virustotal_client.lookup_ip(ip)
|
||||
if result:
|
||||
self.data.add_virustotal_result(ip, result)
|
||||
vt_success_count += 1
|
||||
logger.info(f"🛡️ VirusTotal result for {ip}: {result.positives}/{result.total} detections")
|
||||
else:
|
||||
logger.debug(f"❌ No VirusTotal data for {ip}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Error querying VirusTotal for IP {ip}: {e}")
|
||||
|
||||
# Check domains
|
||||
for hostname in self.data.hostnames:
|
||||
result = self.virustotal_client.lookup_domain(hostname)
|
||||
if result:
|
||||
self.data.virustotal_results[hostname] = result
|
||||
try:
|
||||
logger.debug(f"🔍 Querying VirusTotal for domain: {hostname}")
|
||||
result = self.virustotal_client.lookup_domain(hostname)
|
||||
if result:
|
||||
self.data.add_virustotal_result(hostname, result)
|
||||
vt_success_count += 1
|
||||
logger.info(f"🛡️ VirusTotal result for {hostname}: {result.positives}/{result.total} detections")
|
||||
else:
|
||||
logger.debug(f"❌ No VirusTotal data for {hostname}")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Error querying VirusTotal for domain {hostname}: {e}")
|
||||
|
||||
logger.info(f"✅ VirusTotal lookups complete: {vt_success_count}/{total_resources} successful")
|
||||
else:
|
||||
logger.info("⚠️ Skipping VirusTotal lookups (no API key)")
|
||||
|
||||
# Final external lookup summary
|
||||
ext_stats = {
|
||||
'reverse_dns': len(self.data.reverse_dns),
|
||||
'shodan_results': len(self.data.shodan_results),
|
||||
'virustotal_results': len(self.data.virustotal_results)
|
||||
}
|
||||
logger.info(f"📊 External lookups summary: {ext_stats}")
|
@ -3,10 +3,14 @@
|
||||
|
||||
import requests
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from .data_structures import ShodanResult
|
||||
from .config import Config
|
||||
|
||||
# Module logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class ShodanClient:
|
||||
"""Shodan API client."""
|
||||
|
||||
@ -16,6 +20,8 @@ class ShodanClient:
|
||||
self.api_key = api_key
|
||||
self.config = config
|
||||
self.last_request = 0
|
||||
|
||||
logger.info(f"🕵️ Shodan client initialized with API key ending in: ...{api_key[-4:] if len(api_key) > 4 else api_key}")
|
||||
|
||||
def _rate_limit(self):
|
||||
"""Apply rate limiting for Shodan."""
|
||||
@ -24,7 +30,9 @@ class ShodanClient:
|
||||
min_interval = 1.0 / self.config.SHODAN_RATE_LIMIT
|
||||
|
||||
if time_since_last < min_interval:
|
||||
time.sleep(min_interval - time_since_last)
|
||||
sleep_time = min_interval - time_since_last
|
||||
logger.debug(f"⏸️ Shodan rate limiting: sleeping for {sleep_time:.2f}s")
|
||||
time.sleep(sleep_time)
|
||||
|
||||
self.last_request = time.time()
|
||||
|
||||
@ -32,11 +40,20 @@ class ShodanClient:
|
||||
"""Lookup IP address information."""
|
||||
self._rate_limit()
|
||||
|
||||
logger.debug(f"🔍 Querying Shodan for IP: {ip}")
|
||||
|
||||
try:
|
||||
url = f"{self.BASE_URL}/shodan/host/{ip}"
|
||||
params = {'key': self.api_key}
|
||||
|
||||
response = requests.get(url, params=params, timeout=self.config.HTTP_TIMEOUT)
|
||||
response = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.config.HTTP_TIMEOUT,
|
||||
headers={'User-Agent': 'DNS-Recon-Tool/1.0'}
|
||||
)
|
||||
|
||||
logger.debug(f"📡 Shodan API response for {ip}: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
@ -51,10 +68,10 @@ class ShodanClient:
|
||||
services[str(port)] = {
|
||||
'product': service.get('product', ''),
|
||||
'version': service.get('version', ''),
|
||||
'banner': service.get('data', '').strip()[:200] # Limit banner size
|
||||
'banner': service.get('data', '').strip()[:200] if service.get('data') else ''
|
||||
}
|
||||
|
||||
return ShodanResult(
|
||||
result = ShodanResult(
|
||||
ip=ip,
|
||||
ports=sorted(list(set(ports))),
|
||||
services=services,
|
||||
@ -62,20 +79,43 @@ class ShodanClient:
|
||||
country=data.get('country_name')
|
||||
)
|
||||
|
||||
logger.info(f"✅ Shodan result for {ip}: {len(result.ports)} ports, org: {result.organization}")
|
||||
return result
|
||||
|
||||
elif response.status_code == 404:
|
||||
return None # IP not found in Shodan
|
||||
logger.debug(f"ℹ️ IP {ip} not found in Shodan database")
|
||||
return None
|
||||
elif response.status_code == 401:
|
||||
logger.error("❌ Shodan API key is invalid or expired")
|
||||
return None
|
||||
elif response.status_code == 429:
|
||||
logger.warning("⚠️ Shodan API rate limit exceeded")
|
||||
return None
|
||||
else:
|
||||
print(f"Shodan API error for {ip}: {response.status_code}")
|
||||
logger.warning(f"⚠️ Shodan API error for {ip}: HTTP {response.status_code}")
|
||||
try:
|
||||
error_data = response.json()
|
||||
logger.debug(f"Shodan error details: {error_data}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"⏱️ Shodan query timeout for {ip}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"🌐 Shodan network error for {ip}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error querying Shodan for {ip}: {e}")
|
||||
logger.error(f"❌ Unexpected error querying Shodan for {ip}: {e}")
|
||||
return None
|
||||
|
||||
def search_domain(self, domain: str) -> List[str]:
|
||||
"""Search for IPs associated with a domain."""
|
||||
self._rate_limit()
|
||||
|
||||
logger.debug(f"🔍 Searching Shodan for domain: {domain}")
|
||||
|
||||
try:
|
||||
url = f"{self.BASE_URL}/shodan/host/search"
|
||||
params = {
|
||||
@ -84,7 +124,14 @@ class ShodanClient:
|
||||
'limit': 100
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=self.config.HTTP_TIMEOUT)
|
||||
response = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.config.HTTP_TIMEOUT,
|
||||
headers={'User-Agent': 'DNS-Recon-Tool/1.0'}
|
||||
)
|
||||
|
||||
logger.debug(f"📡 Shodan search response for {domain}: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
@ -95,11 +142,25 @@ class ShodanClient:
|
||||
if ip:
|
||||
ips.append(ip)
|
||||
|
||||
return list(set(ips))
|
||||
unique_ips = list(set(ips))
|
||||
logger.info(f"🔍 Shodan search for {domain} found {len(unique_ips)} unique IPs")
|
||||
return unique_ips
|
||||
elif response.status_code == 401:
|
||||
logger.error("❌ Shodan API key is invalid for search")
|
||||
return []
|
||||
elif response.status_code == 429:
|
||||
logger.warning("⚠️ Shodan search rate limit exceeded")
|
||||
return []
|
||||
else:
|
||||
print(f"Shodan search error for {domain}: {response.status_code}")
|
||||
logger.warning(f"⚠️ Shodan search error for {domain}: HTTP {response.status_code}")
|
||||
return []
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"⏱️ Shodan search timeout for {domain}")
|
||||
return []
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"🌐 Shodan search network error for {domain}: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"Error searching Shodan for {domain}: {e}")
|
||||
logger.error(f"❌ Unexpected error searching Shodan for {domain}: {e}")
|
||||
return []
|
@ -2,10 +2,14 @@
|
||||
"""Fetch and cache IANA TLD list."""
|
||||
|
||||
import requests
|
||||
from typing import List, Set
|
||||
import logging
|
||||
from typing import List, Set, Optional
|
||||
import os
|
||||
import time
|
||||
|
||||
# Module logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class TLDFetcher:
|
||||
"""Fetches and caches IANA TLD list."""
|
||||
|
||||
@ -15,54 +19,124 @@ class TLDFetcher:
|
||||
|
||||
def __init__(self):
|
||||
self._tlds: Optional[Set[str]] = None
|
||||
logger.info("🌐 TLD fetcher initialized")
|
||||
|
||||
def get_tlds(self) -> Set[str]:
|
||||
"""Get list of TLDs, using cache if available."""
|
||||
if self._tlds is None:
|
||||
logger.debug("🔍 Loading TLD list...")
|
||||
self._tlds = self._load_tlds()
|
||||
logger.info(f"✅ Loaded {len(self._tlds)} TLDs")
|
||||
return self._tlds
|
||||
|
||||
def _load_tlds(self) -> Set[str]:
|
||||
"""Load TLDs from cache or fetch from IANA."""
|
||||
if self._is_cache_valid():
|
||||
logger.debug("📂 Loading TLDs from cache")
|
||||
return self._load_from_cache()
|
||||
return self._fetch_and_cache()
|
||||
else:
|
||||
logger.info("🌐 Fetching fresh TLD list from IANA")
|
||||
return self._fetch_and_cache()
|
||||
|
||||
def _is_cache_valid(self) -> bool:
|
||||
"""Check if cache file exists and is recent."""
|
||||
if not os.path.exists(self.CACHE_FILE):
|
||||
logger.debug("❌ TLD cache file does not exist")
|
||||
return False
|
||||
|
||||
cache_age = time.time() - os.path.getmtime(self.CACHE_FILE)
|
||||
return cache_age < self.CACHE_DURATION
|
||||
is_valid = cache_age < self.CACHE_DURATION
|
||||
|
||||
if is_valid:
|
||||
logger.debug(f"✅ TLD cache is valid (age: {cache_age/3600:.1f} hours)")
|
||||
else:
|
||||
logger.debug(f"❌ TLD cache is expired (age: {cache_age/3600:.1f} hours)")
|
||||
|
||||
return is_valid
|
||||
|
||||
def _load_from_cache(self) -> Set[str]:
|
||||
"""Load TLDs from cache file."""
|
||||
with open(self.CACHE_FILE, 'r') as f:
|
||||
return set(line.strip().lower() for line in f if not line.startswith('#'))
|
||||
try:
|
||||
with open(self.CACHE_FILE, 'r', encoding='utf-8') as f:
|
||||
tlds = set()
|
||||
for line in f:
|
||||
line = line.strip().lower()
|
||||
if line and not line.startswith('#'):
|
||||
tlds.add(line)
|
||||
|
||||
logger.info(f"📂 Loaded {len(tlds)} TLDs from cache")
|
||||
return tlds
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error loading TLD cache: {e}")
|
||||
# Fall back to fetching fresh data
|
||||
return self._fetch_and_cache()
|
||||
|
||||
def _fetch_and_cache(self) -> Set[str]:
|
||||
"""Fetch TLDs from IANA and cache them."""
|
||||
try:
|
||||
response = requests.get(self.IANA_TLD_URL, timeout=10)
|
||||
logger.info(f"📡 Fetching TLD list from: {self.IANA_TLD_URL}")
|
||||
|
||||
response = requests.get(
|
||||
self.IANA_TLD_URL,
|
||||
timeout=30,
|
||||
headers={'User-Agent': 'DNS-Recon-Tool/1.0'}
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
tlds = set()
|
||||
lines_processed = 0
|
||||
|
||||
for line in response.text.split('\n'):
|
||||
line = line.strip().lower()
|
||||
if line and not line.startswith('#'):
|
||||
tlds.add(line)
|
||||
lines_processed += 1
|
||||
|
||||
logger.info(f"✅ Fetched {len(tlds)} TLDs from IANA (processed {lines_processed} lines)")
|
||||
|
||||
# Cache the results
|
||||
with open(self.CACHE_FILE, 'w') as f:
|
||||
f.write(response.text)
|
||||
try:
|
||||
with open(self.CACHE_FILE, 'w', encoding='utf-8') as f:
|
||||
f.write(response.text)
|
||||
logger.info(f"💾 TLD list cached to {self.CACHE_FILE}")
|
||||
except Exception as cache_error:
|
||||
logger.warning(f"⚠️ Could not cache TLD list: {cache_error}")
|
||||
|
||||
return tlds
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.error("⏱️ Timeout fetching TLD list from IANA")
|
||||
return self._get_fallback_tlds()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"🌐 Network error fetching TLD list: {e}")
|
||||
return self._get_fallback_tlds()
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch TLD list: {e}")
|
||||
# Return a minimal set if fetch fails
|
||||
return {
|
||||
'com', 'org', 'net', 'edu', 'gov', 'mil', 'int',
|
||||
'co.uk', 'org.uk', 'ac.uk', 'de', 'fr', 'it', 'nl', 'be'
|
||||
}
|
||||
logger.error(f"❌ Unexpected error fetching TLD list: {e}")
|
||||
return self._get_fallback_tlds()
|
||||
|
||||
def _get_fallback_tlds(self) -> Set[str]:
|
||||
"""Return a minimal set of common TLDs if fetch fails."""
|
||||
logger.warning("⚠️ Using fallback TLD list")
|
||||
|
||||
fallback_tlds = {
|
||||
# Generic top-level domains
|
||||
'com', 'org', 'net', 'edu', 'gov', 'mil', 'int', 'info', 'biz', 'name',
|
||||
|
||||
# Country code top-level domains (major ones)
|
||||
'us', 'uk', 'de', 'fr', 'it', 'es', 'nl', 'be', 'ch', 'at', 'se', 'no',
|
||||
'dk', 'fi', 'pl', 'cz', 'hu', 'ro', 'bg', 'hr', 'si', 'sk', 'lt', 'lv',
|
||||
'ee', 'ie', 'pt', 'gr', 'cy', 'mt', 'lu', 'is', 'li', 'ad', 'mc', 'sm',
|
||||
'va', 'by', 'ua', 'md', 'ru', 'kz', 'kg', 'tj', 'tm', 'uz', 'am', 'az',
|
||||
'ge', 'tr', 'il', 'jo', 'lb', 'sy', 'iq', 'ir', 'af', 'pk', 'in', 'lk',
|
||||
'mv', 'bt', 'bd', 'np', 'mm', 'th', 'la', 'kh', 'vn', 'my', 'sg', 'bn',
|
||||
'id', 'tl', 'ph', 'tw', 'hk', 'mo', 'cn', 'kp', 'kr', 'jp', 'mn',
|
||||
|
||||
# Common compound TLDs
|
||||
'co.uk', 'org.uk', 'ac.uk', 'gov.uk', 'com.au', 'org.au', 'net.au',
|
||||
'gov.au', 'edu.au', 'co.za', 'org.za', 'net.za', 'gov.za', 'ac.za',
|
||||
'co.nz', 'org.nz', 'net.nz', 'govt.nz', 'ac.nz', 'co.jp', 'or.jp',
|
||||
'ne.jp', 'go.jp', 'ac.jp', 'ad.jp', 'ed.jp', 'gr.jp', 'lg.jp'
|
||||
}
|
||||
|
||||
logger.info(f"📋 Using {len(fallback_tlds)} fallback TLDs")
|
||||
return fallback_tlds
|
@ -3,11 +3,15 @@
|
||||
|
||||
import requests
|
||||
import time
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from .data_structures import VirusTotalResult
|
||||
from .config import Config
|
||||
|
||||
# Module logger
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class VirusTotalClient:
|
||||
"""VirusTotal API client."""
|
||||
|
||||
@ -17,6 +21,8 @@ class VirusTotalClient:
|
||||
self.api_key = api_key
|
||||
self.config = config
|
||||
self.last_request = 0
|
||||
|
||||
logger.info(f"🛡️ VirusTotal client initialized with API key ending in: ...{api_key[-4:] if len(api_key) > 4 else api_key}")
|
||||
|
||||
def _rate_limit(self):
|
||||
"""Apply rate limiting for VirusTotal."""
|
||||
@ -25,7 +31,9 @@ class VirusTotalClient:
|
||||
min_interval = 1.0 / self.config.VIRUSTOTAL_RATE_LIMIT
|
||||
|
||||
if time_since_last < min_interval:
|
||||
time.sleep(min_interval - time_since_last)
|
||||
sleep_time = min_interval - time_since_last
|
||||
logger.debug(f"⏸️ VirusTotal rate limiting: sleeping for {sleep_time:.2f}s")
|
||||
time.sleep(sleep_time)
|
||||
|
||||
self.last_request = time.time()
|
||||
|
||||
@ -33,6 +41,8 @@ class VirusTotalClient:
|
||||
"""Lookup IP address reputation."""
|
||||
self._rate_limit()
|
||||
|
||||
logger.debug(f"🔍 Querying VirusTotal for IP: {ip}")
|
||||
|
||||
try:
|
||||
url = f"{self.BASE_URL}/ip-address/report"
|
||||
params = {
|
||||
@ -40,34 +50,84 @@ class VirusTotalClient:
|
||||
'ip': ip
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=self.config.HTTP_TIMEOUT)
|
||||
response = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.config.HTTP_TIMEOUT,
|
||||
headers={'User-Agent': 'DNS-Recon-Tool/1.0'}
|
||||
)
|
||||
|
||||
logger.debug(f"📡 VirusTotal API response for IP {ip}: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
logger.debug(f"VirusTotal IP response data keys: {data.keys()}")
|
||||
|
||||
if data.get('response_code') == 1:
|
||||
return VirusTotalResult(
|
||||
# Count detected URLs
|
||||
detected_urls = data.get('detected_urls', [])
|
||||
positives = sum(1 for url in detected_urls if url.get('positives', 0) > 0)
|
||||
total = len(detected_urls)
|
||||
|
||||
# Parse scan date
|
||||
scan_date = datetime.now()
|
||||
if data.get('scan_date'):
|
||||
try:
|
||||
scan_date = datetime.fromisoformat(data['scan_date'].replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
try:
|
||||
scan_date = datetime.strptime(data['scan_date'], '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
logger.debug(f"Could not parse scan_date: {data.get('scan_date')}")
|
||||
|
||||
result = VirusTotalResult(
|
||||
resource=ip,
|
||||
positives=data.get('detected_urls', []) and len([
|
||||
url for url in data.get('detected_urls', [])
|
||||
if url.get('positives', 0) > 0
|
||||
]) or 0,
|
||||
total=len(data.get('detected_urls', [])),
|
||||
scan_date=datetime.fromisoformat(
|
||||
data.get('scan_date', datetime.now().isoformat())
|
||||
) if data.get('scan_date') else datetime.now(),
|
||||
permalink=data.get('permalink', '')
|
||||
positives=positives,
|
||||
total=total,
|
||||
scan_date=scan_date,
|
||||
permalink=data.get('permalink', f'https://www.virustotal.com/gui/ip-address/{ip}')
|
||||
)
|
||||
|
||||
logger.info(f"✅ VirusTotal result for IP {ip}: {result.positives}/{result.total} detections")
|
||||
return result
|
||||
elif data.get('response_code') == 0:
|
||||
logger.debug(f"ℹ️ IP {ip} not found in VirusTotal database")
|
||||
return None
|
||||
else:
|
||||
logger.debug(f"VirusTotal returned response_code: {data.get('response_code')}")
|
||||
return None
|
||||
elif response.status_code == 204:
|
||||
logger.warning("⚠️ VirusTotal API rate limit exceeded")
|
||||
return None
|
||||
elif response.status_code == 403:
|
||||
logger.error("❌ VirusTotal API key is invalid or lacks permissions")
|
||||
return None
|
||||
else:
|
||||
logger.warning(f"⚠️ VirusTotal API error for IP {ip}: HTTP {response.status_code}")
|
||||
try:
|
||||
error_data = response.json()
|
||||
logger.debug(f"VirusTotal error details: {error_data}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"⏱️ VirusTotal query timeout for IP {ip}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"🌐 VirusTotal network error for IP {ip}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error querying VirusTotal for {ip}: {e}")
|
||||
|
||||
return None
|
||||
logger.error(f"❌ Unexpected error querying VirusTotal for IP {ip}: {e}")
|
||||
return None
|
||||
|
||||
def lookup_domain(self, domain: str) -> Optional[VirusTotalResult]:
|
||||
"""Lookup domain reputation."""
|
||||
self._rate_limit()
|
||||
|
||||
logger.debug(f"🔍 Querying VirusTotal for domain: {domain}")
|
||||
|
||||
try:
|
||||
url = f"{self.BASE_URL}/domain/report"
|
||||
params = {
|
||||
@ -75,26 +135,80 @@ class VirusTotalClient:
|
||||
'domain': domain
|
||||
}
|
||||
|
||||
response = requests.get(url, params=params, timeout=self.config.HTTP_TIMEOUT)
|
||||
response = requests.get(
|
||||
url,
|
||||
params=params,
|
||||
timeout=self.config.HTTP_TIMEOUT,
|
||||
headers={'User-Agent': 'DNS-Recon-Tool/1.0'}
|
||||
)
|
||||
|
||||
logger.debug(f"📡 VirusTotal API response for domain {domain}: {response.status_code}")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
logger.debug(f"VirusTotal domain response data keys: {data.keys()}")
|
||||
|
||||
if data.get('response_code') == 1:
|
||||
return VirusTotalResult(
|
||||
# Count detected URLs
|
||||
detected_urls = data.get('detected_urls', [])
|
||||
positives = sum(1 for url in detected_urls if url.get('positives', 0) > 0)
|
||||
total = len(detected_urls)
|
||||
|
||||
# Also check for malicious/suspicious categories
|
||||
categories = data.get('categories', [])
|
||||
if any(cat in ['malicious', 'suspicious', 'phishing', 'malware']
|
||||
for cat in categories):
|
||||
positives += 1
|
||||
|
||||
# Parse scan date
|
||||
scan_date = datetime.now()
|
||||
if data.get('scan_date'):
|
||||
try:
|
||||
scan_date = datetime.fromisoformat(data['scan_date'].replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
try:
|
||||
scan_date = datetime.strptime(data['scan_date'], '%Y-%m-%d %H:%M:%S')
|
||||
except ValueError:
|
||||
logger.debug(f"Could not parse scan_date: {data.get('scan_date')}")
|
||||
|
||||
result = VirusTotalResult(
|
||||
resource=domain,
|
||||
positives=data.get('detected_urls', []) and len([
|
||||
url for url in data.get('detected_urls', [])
|
||||
if url.get('positives', 0) > 0
|
||||
]) or 0,
|
||||
total=len(data.get('detected_urls', [])),
|
||||
scan_date=datetime.fromisoformat(
|
||||
data.get('scan_date', datetime.now().isoformat())
|
||||
) if data.get('scan_date') else datetime.now(),
|
||||
permalink=data.get('permalink', '')
|
||||
positives=positives,
|
||||
total=max(total, 1), # Ensure total is at least 1
|
||||
scan_date=scan_date,
|
||||
permalink=data.get('permalink', f'https://www.virustotal.com/gui/domain/{domain}')
|
||||
)
|
||||
|
||||
logger.info(f"✅ VirusTotal result for domain {domain}: {result.positives}/{result.total} detections")
|
||||
return result
|
||||
elif data.get('response_code') == 0:
|
||||
logger.debug(f"ℹ️ Domain {domain} not found in VirusTotal database")
|
||||
return None
|
||||
else:
|
||||
logger.debug(f"VirusTotal returned response_code: {data.get('response_code')}")
|
||||
return None
|
||||
elif response.status_code == 204:
|
||||
logger.warning("⚠️ VirusTotal API rate limit exceeded")
|
||||
return None
|
||||
elif response.status_code == 403:
|
||||
logger.error("❌ VirusTotal API key is invalid or lacks permissions")
|
||||
return None
|
||||
else:
|
||||
logger.warning(f"⚠️ VirusTotal API error for domain {domain}: HTTP {response.status_code}")
|
||||
try:
|
||||
error_data = response.json()
|
||||
logger.debug(f"VirusTotal error details: {error_data}")
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(f"⏱️ VirusTotal query timeout for domain {domain}")
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"🌐 VirusTotal network error for domain {domain}: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"Error querying VirusTotal for {domain}: {e}")
|
||||
|
||||
return None
|
||||
logger.error(f"❌ Unexpected error querying VirusTotal for domain {domain}: {e}")
|
||||
return None
|
165
src/web_app.py
165
src/web_app.py
@ -4,10 +4,14 @@
|
||||
from flask import Flask, render_template, request, jsonify, send_from_directory
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from .config import Config
|
||||
from .reconnaissance import ReconnaissanceEngine
|
||||
from .report_generator import ReportGenerator
|
||||
|
||||
# Set up logging for this module
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global variables for tracking ongoing scans
|
||||
active_scans = {}
|
||||
scan_lock = threading.Lock()
|
||||
@ -20,6 +24,10 @@ def create_app(config: Config):
|
||||
|
||||
app.config['SECRET_KEY'] = 'recon-tool-secret-key'
|
||||
|
||||
# Set up logging for web app
|
||||
config.setup_logging(cli_mode=False)
|
||||
logger.info("🌐 Web application initialized")
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
"""Main page."""
|
||||
@ -28,52 +36,68 @@ def create_app(config: Config):
|
||||
@app.route('/api/scan', methods=['POST'])
|
||||
def start_scan():
|
||||
"""Start a new reconnaissance scan."""
|
||||
data = request.get_json()
|
||||
target = data.get('target')
|
||||
scan_config = Config.from_args(
|
||||
shodan_key=data.get('shodan_key'),
|
||||
virustotal_key=data.get('virustotal_key'),
|
||||
max_depth=data.get('max_depth', 2)
|
||||
)
|
||||
try:
|
||||
data = request.get_json()
|
||||
target = data.get('target')
|
||||
scan_config = Config.from_args(
|
||||
shodan_key=data.get('shodan_key'),
|
||||
virustotal_key=data.get('virustotal_key'),
|
||||
max_depth=data.get('max_depth', 2)
|
||||
)
|
||||
|
||||
if not target:
|
||||
logger.warning("⚠️ Scan request missing target")
|
||||
return jsonify({'error': 'Target is required'}), 400
|
||||
|
||||
# Generate scan ID
|
||||
scan_id = f"{target}_{int(time.time())}"
|
||||
logger.info(f"🚀 Starting new scan: {scan_id} for target: {target}")
|
||||
|
||||
# Initialize scan data
|
||||
with scan_lock:
|
||||
active_scans[scan_id] = {
|
||||
'status': 'starting',
|
||||
'progress': 0,
|
||||
'message': 'Initializing...',
|
||||
'data': None,
|
||||
'error': None,
|
||||
'live_stats': {
|
||||
'hostnames': 0,
|
||||
'ip_addresses': 0,
|
||||
'dns_records': 0,
|
||||
'certificates': 0,
|
||||
'shodan_results': 0,
|
||||
'virustotal_results': 0
|
||||
},
|
||||
'latest_discoveries': []
|
||||
}
|
||||
|
||||
# Start reconnaissance in background thread
|
||||
thread = threading.Thread(
|
||||
target=run_reconnaissance_background,
|
||||
args=(scan_id, target, scan_config)
|
||||
)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return jsonify({'scan_id': scan_id})
|
||||
|
||||
if not target:
|
||||
return jsonify({'error': 'Target is required'}), 400
|
||||
|
||||
# Generate scan ID
|
||||
scan_id = f"{target}_{int(time.time())}"
|
||||
|
||||
# Initialize scan data
|
||||
with scan_lock:
|
||||
active_scans[scan_id] = {
|
||||
'status': 'starting',
|
||||
'progress': 0,
|
||||
'message': 'Initializing...',
|
||||
'data': None,
|
||||
'error': None
|
||||
}
|
||||
|
||||
# Start reconnaissance in background thread
|
||||
thread = threading.Thread(
|
||||
target=run_reconnaissance_background,
|
||||
args=(scan_id, target, scan_config)
|
||||
)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
return jsonify({'scan_id': scan_id})
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error starting scan: {e}", exc_info=True)
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/scan/<scan_id>/status')
|
||||
def get_scan_status(scan_id):
|
||||
"""Get scan status and progress."""
|
||||
"""Get scan status and progress with live discoveries."""
|
||||
with scan_lock:
|
||||
if scan_id not in active_scans:
|
||||
return jsonify({'error': 'Scan not found'}), 404
|
||||
|
||||
scan_data = active_scans[scan_id].copy()
|
||||
|
||||
# Convert ReconData object to a dict to make it JSON serializable
|
||||
if scan_data.get('data'):
|
||||
scan_data['data'] = scan_data['data'].to_dict()
|
||||
|
||||
# Don't include the full data object in status (too large)
|
||||
if 'data' in scan_data:
|
||||
del scan_data['data']
|
||||
|
||||
return jsonify(scan_data)
|
||||
|
||||
@ -88,14 +112,42 @@ def create_app(config: Config):
|
||||
|
||||
if scan_data['status'] != 'completed' or not scan_data['data']:
|
||||
return jsonify({'error': 'Scan not completed'}), 400
|
||||
|
||||
|
||||
try:
|
||||
# Generate report
|
||||
report_gen = ReportGenerator(scan_data['data'])
|
||||
|
||||
return jsonify({
|
||||
'json_report': scan_data['data'].to_dict(), # Use to_dict for a clean JSON object
|
||||
'json_report': scan_data['data'].to_json(), # This should now work properly
|
||||
'text_report': report_gen.generate_text_report()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error generating report for {scan_id}: {e}", exc_info=True)
|
||||
return jsonify({'error': f'Failed to generate report: {str(e)}'}), 500
|
||||
|
||||
@app.route('/api/scan/<scan_id>/live-data')
|
||||
def get_live_scan_data(scan_id):
|
||||
"""Get current reconnaissance data (for real-time updates)."""
|
||||
with scan_lock:
|
||||
if scan_id not in active_scans:
|
||||
return jsonify({'error': 'Scan not found'}), 404
|
||||
|
||||
scan_data = active_scans[scan_id]
|
||||
|
||||
if not scan_data['data']:
|
||||
return jsonify({
|
||||
'hostnames': [],
|
||||
'ip_addresses': [],
|
||||
'stats': scan_data['live_stats']
|
||||
})
|
||||
|
||||
# Return current discoveries
|
||||
return jsonify({
|
||||
'hostnames': sorted(list(scan_data['data'].hostnames)),
|
||||
'ip_addresses': sorted(list(scan_data['data'].ip_addresses)),
|
||||
'stats': scan_data['data'].get_stats(),
|
||||
'latest_discoveries': scan_data.get('latest_discoveries', [])
|
||||
})
|
||||
|
||||
return app
|
||||
|
||||
@ -109,8 +161,29 @@ def run_reconnaissance_background(scan_id: str, target: str, config: Config):
|
||||
active_scans[scan_id]['message'] = message
|
||||
if percentage is not None:
|
||||
active_scans[scan_id]['progress'] = percentage
|
||||
|
||||
# Update live stats if we have data
|
||||
if active_scans[scan_id]['data']:
|
||||
active_scans[scan_id]['live_stats'] = active_scans[scan_id]['data'].get_stats()
|
||||
|
||||
# Add to latest discoveries (keep last 10)
|
||||
if 'latest_discoveries' not in active_scans[scan_id]:
|
||||
active_scans[scan_id]['latest_discoveries'] = []
|
||||
|
||||
active_scans[scan_id]['latest_discoveries'].append({
|
||||
'timestamp': time.time(),
|
||||
'message': message
|
||||
})
|
||||
|
||||
# Keep only last 10 discoveries
|
||||
active_scans[scan_id]['latest_discoveries'] = \
|
||||
active_scans[scan_id]['latest_discoveries'][-10:]
|
||||
|
||||
logger.info(f"[{scan_id}] {message} ({percentage}%)" if percentage else f"[{scan_id}] {message}")
|
||||
|
||||
try:
|
||||
logger.info(f"🔧 Initializing reconnaissance engine for scan: {scan_id}")
|
||||
|
||||
# Initialize engine
|
||||
engine = ReconnaissanceEngine(config)
|
||||
engine.set_progress_callback(update_progress)
|
||||
@ -119,21 +192,29 @@ def run_reconnaissance_background(scan_id: str, target: str, config: Config):
|
||||
with scan_lock:
|
||||
active_scans[scan_id]['status'] = 'running'
|
||||
|
||||
logger.info(f"🚀 Starting reconnaissance for: {target}")
|
||||
|
||||
# Run reconnaissance
|
||||
data = engine.run_reconnaissance(target)
|
||||
|
||||
logger.info(f"✅ Reconnaissance completed for scan: {scan_id}")
|
||||
|
||||
# Update with results
|
||||
with scan_lock:
|
||||
active_scans[scan_id]['status'] = 'completed'
|
||||
active_scans[scan_id]['progress'] = 100
|
||||
active_scans[scan_id]['message'] = 'Reconnaissance completed'
|
||||
active_scans[scan_id]['data'] = data
|
||||
active_scans[scan_id]['live_stats'] = data.get_stats()
|
||||
|
||||
# Log final statistics
|
||||
final_stats = data.get_stats()
|
||||
logger.info(f"📊 Final stats for {scan_id}: {final_stats}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error in reconnaissance for {scan_id}: {e}", exc_info=True)
|
||||
# Handle errors
|
||||
with scan_lock:
|
||||
active_scans[scan_id]['status'] = 'error'
|
||||
active_scans[scan_id]['error'] = str(e)
|
||||
active_scans[scan_id]['message'] = f'Error: {str(e)}'
|
||||
|
||||
|
||||
active_scans[scan_id]['message'] = f'Error: {str(e)}'
|
210
static/script.js
210
static/script.js
@ -1,15 +1,73 @@
|
||||
// DNS Reconnaissance Tool - Frontend JavaScript
|
||||
// DNS Reconnaissance Tool - Enhanced Frontend JavaScript with Real-time Updates
|
||||
|
||||
class ReconTool {
|
||||
constructor() {
|
||||
this.currentScanId = null;
|
||||
this.pollInterval = null;
|
||||
this.liveDataInterval = null;
|
||||
this.currentReport = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.setupRealtimeElements();
|
||||
}
|
||||
|
||||
setupRealtimeElements() {
|
||||
// Create live discovery container if it doesn't exist
|
||||
if (!document.getElementById('liveDiscoveries')) {
|
||||
const progressSection = document.getElementById('progressSection');
|
||||
const liveDiv = document.createElement('div');
|
||||
liveDiv.id = 'liveDiscoveries';
|
||||
liveDiv.innerHTML = `
|
||||
<div class="live-discoveries" style="display: none;">
|
||||
<h3>🔍 Live Discoveries</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Hostnames:</span>
|
||||
<span id="liveHostnames" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">IP Addresses:</span>
|
||||
<span id="liveIPs" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">DNS Records:</span>
|
||||
<span id="liveDNS" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Certificates:</span>
|
||||
<span id="liveCerts" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Shodan Results:</span>
|
||||
<span id="liveShodan" class="stat-value">0</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">VirusTotal:</span>
|
||||
<span id="liveVT" class="stat-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discoveries-list">
|
||||
<h4>📋 Recent Discoveries</h4>
|
||||
<div id="recentHostnames" class="discovery-section">
|
||||
<strong>Hostnames:</strong>
|
||||
<div class="hostname-list"></div>
|
||||
</div>
|
||||
<div id="recentIPs" class="discovery-section">
|
||||
<strong>IP Addresses:</strong>
|
||||
<div class="ip-list"></div>
|
||||
</div>
|
||||
<div id="activityLog" class="discovery-section">
|
||||
<strong>Activity Log:</strong>
|
||||
<div class="activity-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
progressSection.appendChild(liveDiv);
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
@ -69,6 +127,8 @@ class ReconTool {
|
||||
this.showProgressSection();
|
||||
this.updateProgress(0, 'Starting scan...');
|
||||
|
||||
console.log('🚀 Starting scan with data:', scanData);
|
||||
|
||||
const response = await fetch('/api/scan', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@ -88,15 +148,19 @@ class ReconTool {
|
||||
}
|
||||
|
||||
this.currentScanId = result.scan_id;
|
||||
console.log('✅ Scan started with ID:', this.currentScanId);
|
||||
|
||||
this.startPolling();
|
||||
this.startLiveDataPolling();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to start scan:', error);
|
||||
this.showError(`Failed to start scan: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
startPolling() {
|
||||
// Poll every 2 seconds for updates
|
||||
// Poll every 2 seconds for status updates
|
||||
this.pollInterval = setInterval(() => {
|
||||
this.checkScanStatus();
|
||||
}, 2000);
|
||||
@ -104,6 +168,22 @@ class ReconTool {
|
||||
// Also check immediately
|
||||
this.checkScanStatus();
|
||||
}
|
||||
|
||||
startLiveDataPolling() {
|
||||
// Poll every 3 seconds for live data updates
|
||||
this.liveDataInterval = setInterval(() => {
|
||||
this.updateLiveData();
|
||||
}, 3000);
|
||||
|
||||
// Show the live discoveries section
|
||||
const liveSection = document.querySelector('.live-discoveries');
|
||||
if (liveSection) {
|
||||
liveSection.style.display = 'block';
|
||||
}
|
||||
|
||||
// Also update immediately
|
||||
this.updateLiveData();
|
||||
}
|
||||
|
||||
async checkScanStatus() {
|
||||
if (!this.currentScanId) {
|
||||
@ -125,9 +205,15 @@ class ReconTool {
|
||||
|
||||
// Update progress
|
||||
this.updateProgress(status.progress, status.message);
|
||||
|
||||
// Update live stats
|
||||
if (status.live_stats) {
|
||||
this.updateLiveStats(status.live_stats);
|
||||
}
|
||||
|
||||
// Check if completed
|
||||
if (status.status === 'completed') {
|
||||
console.log('✅ Scan completed');
|
||||
this.stopPolling();
|
||||
await this.loadScanReport();
|
||||
} else if (status.status === 'error') {
|
||||
@ -136,13 +222,101 @@ class ReconTool {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error checking scan status:', error);
|
||||
this.stopPolling();
|
||||
this.showError(`Error checking scan status: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async updateLiveData() {
|
||||
if (!this.currentScanId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scan/${this.currentScanId}/live-data`);
|
||||
|
||||
if (!response.ok) {
|
||||
return; // Silently fail for live data
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
return; // Silently fail for live data
|
||||
}
|
||||
|
||||
// Update live discoveries
|
||||
this.updateLiveDiscoveries(data);
|
||||
|
||||
} catch (error) {
|
||||
// Silently fail for live data updates
|
||||
console.debug('Live data update failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
updateLiveStats(stats) {
|
||||
// Update the live statistics counters
|
||||
const statElements = {
|
||||
'liveHostnames': stats.hostnames || 0,
|
||||
'liveIPs': stats.ip_addresses || 0,
|
||||
'liveDNS': stats.dns_records || 0,
|
||||
'liveCerts': stats.certificates || 0,
|
||||
'liveShodan': stats.shodan_results || 0,
|
||||
'liveVT': stats.virustotal_results || 0
|
||||
};
|
||||
|
||||
Object.entries(statElements).forEach(([elementId, value]) => {
|
||||
const element = document.getElementById(elementId);
|
||||
if (element) {
|
||||
element.textContent = value;
|
||||
|
||||
// Add a brief highlight effect when value changes
|
||||
if (element.textContent !== value.toString()) {
|
||||
element.style.backgroundColor = '#ff9900';
|
||||
setTimeout(() => {
|
||||
element.style.backgroundColor = '';
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateLiveDiscoveries(data) {
|
||||
// Update hostnames list
|
||||
const hostnameList = document.querySelector('#recentHostnames .hostname-list');
|
||||
if (hostnameList && data.hostnames) {
|
||||
// Show last 10 hostnames
|
||||
const recentHostnames = data.hostnames.slice(-10);
|
||||
hostnameList.innerHTML = recentHostnames.map(hostname =>
|
||||
`<span class="discovery-item">${hostname}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Update IP addresses list
|
||||
const ipList = document.querySelector('#recentIPs .ip-list');
|
||||
if (ipList && data.ip_addresses) {
|
||||
// Show last 10 IPs
|
||||
const recentIPs = data.ip_addresses.slice(-10);
|
||||
ipList.innerHTML = recentIPs.map(ip =>
|
||||
`<span class="discovery-item">${ip}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Update activity log
|
||||
const activityList = document.querySelector('#activityLog .activity-list');
|
||||
if (activityList && data.latest_discoveries) {
|
||||
const activities = data.latest_discoveries.slice(-5); // Last 5 activities
|
||||
activityList.innerHTML = activities.map(activity => {
|
||||
const time = new Date(activity.timestamp * 1000).toLocaleTimeString();
|
||||
return `<div class="activity-item">[${time}] ${activity.message}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
async loadScanReport() {
|
||||
try {
|
||||
console.log('📄 Loading scan report...');
|
||||
const response = await fetch(`/api/scan/${this.currentScanId}/report`);
|
||||
|
||||
if (!response.ok) {
|
||||
@ -156,10 +330,12 @@ class ReconTool {
|
||||
}
|
||||
|
||||
this.currentReport = report;
|
||||
console.log('✅ Report loaded successfully');
|
||||
this.showResultsSection();
|
||||
this.showReport('text'); // Default to text view
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading report:', error);
|
||||
this.showError(`Error loading report: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@ -169,6 +345,10 @@ class ReconTool {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
if (this.liveDataInterval) {
|
||||
clearInterval(this.liveDataInterval);
|
||||
this.liveDataInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
showProgressSection() {
|
||||
@ -181,6 +361,12 @@ class ReconTool {
|
||||
document.getElementById('scanForm').style.display = 'none';
|
||||
document.getElementById('progressSection').style.display = 'none';
|
||||
document.getElementById('resultsSection').style.display = 'block';
|
||||
|
||||
// Hide live discoveries in results section
|
||||
const liveSection = document.querySelector('.live-discoveries');
|
||||
if (liveSection) {
|
||||
liveSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
resetToForm() {
|
||||
@ -192,6 +378,12 @@ class ReconTool {
|
||||
document.getElementById('progressSection').style.display = 'none';
|
||||
document.getElementById('resultsSection').style.display = 'none';
|
||||
|
||||
// Hide live discoveries
|
||||
const liveSection = document.querySelector('.live-discoveries');
|
||||
if (liveSection) {
|
||||
liveSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Clear form
|
||||
document.getElementById('target').value = '';
|
||||
document.getElementById('shodanKey').value = '';
|
||||
@ -227,9 +419,16 @@ class ReconTool {
|
||||
if (type === 'json') {
|
||||
// Show JSON report
|
||||
try {
|
||||
const jsonData = JSON.parse(this.currentReport.json_report);
|
||||
// The json_report should already be a string from the server
|
||||
let jsonData;
|
||||
if (typeof this.currentReport.json_report === 'string') {
|
||||
jsonData = JSON.parse(this.currentReport.json_report);
|
||||
} else {
|
||||
jsonData = this.currentReport.json_report;
|
||||
}
|
||||
reportContent.textContent = JSON.stringify(jsonData, null, 2);
|
||||
} catch (e) {
|
||||
console.error('Error parsing JSON report:', e);
|
||||
reportContent.textContent = this.currentReport.json_report;
|
||||
}
|
||||
|
||||
@ -252,7 +451,9 @@ class ReconTool {
|
||||
let content, filename, mimeType;
|
||||
|
||||
if (type === 'json') {
|
||||
content = this.currentReport.json_report;
|
||||
content = typeof this.currentReport.json_report === 'string'
|
||||
? this.currentReport.json_report
|
||||
: JSON.stringify(this.currentReport.json_report, null, 2);
|
||||
filename = `recon-report-${this.currentScanId}.json`;
|
||||
mimeType = 'application/json';
|
||||
} else {
|
||||
@ -276,5 +477,6 @@ class ReconTool {
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('🌐 DNS Reconnaissance Tool initialized');
|
||||
new ReconTool();
|
||||
});
|
124
static/style.css
124
static/style.css
@ -211,6 +211,40 @@ header p {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
|
||||
.hostname-list, .ip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.discovery-item {
|
||||
background: #2a2a2a;
|
||||
color: #00ff41;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
color: #a0a0a0;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.8rem;
|
||||
padding: 2px 0;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Responsive design adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
@ -240,6 +274,23 @@ header p {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.stat-label, .stat-value {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.hostname-list, .ip-list {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tactical loading spinner */
|
||||
@ -253,6 +304,79 @@ header p {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.live-discoveries {
|
||||
background: rgba(0, 20, 0, 0.6);
|
||||
border: 1px solid #00ff41;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.live-discoveries h3 {
|
||||
color: #00ff41;
|
||||
margin-bottom: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid #333;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #a0a0a0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #00ff41;
|
||||
font-weight: bold;
|
||||
font-family: 'Courier New', monospace;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.discoveries-list {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.discoveries-list h4 {
|
||||
color: #ff9900;
|
||||
margin-bottom: 15px;
|
||||
border-bottom: 1px solid #444;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.discovery-section {
|
||||
margin-bottom: 15px;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid #333;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.discovery-section strong {
|
||||
color: #c7c7c7;
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user