progress
This commit is contained in:
parent
696cec0723
commit
ce0e11cf0b
18
app.py
18
app.py
@ -6,7 +6,7 @@ Provides REST API endpoints and serves the web interface.
|
||||
import json
|
||||
import traceback
|
||||
from flask import Flask, render_template, request, jsonify, send_file
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
import io
|
||||
|
||||
from core.scanner import scanner
|
||||
@ -173,7 +173,7 @@ def export_results():
|
||||
results = scanner.export_results()
|
||||
|
||||
# Create filename with timestamp
|
||||
timestamp = datetime.now(datetime.UTC).strftime('%Y%m%d_%H%M%S')
|
||||
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
|
||||
target = scanner.current_target or 'unknown'
|
||||
filename = f"dnsrecon_{target}_{timestamp}.json"
|
||||
|
||||
@ -284,12 +284,22 @@ def set_api_keys():
|
||||
|
||||
@app.route('/api/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""Health check endpoint."""
|
||||
"""Health check endpoint with enhanced Phase 2 information."""
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'status': 'healthy',
|
||||
'timestamp': datetime.now(datetime.UTC).isoformat(),
|
||||
'version': '1.0.0-phase1'
|
||||
'version': '1.0.0-phase2',
|
||||
'phase': 2,
|
||||
'features': {
|
||||
'multi_provider': True,
|
||||
'concurrent_processing': True,
|
||||
'real_time_updates': True,
|
||||
'api_key_management': True,
|
||||
'enhanced_visualization': True,
|
||||
'retry_logic': True
|
||||
},
|
||||
'providers_available': len(scanner.providers) if hasattr(scanner, 'providers') else 0
|
||||
})
|
||||
|
||||
|
||||
|
@ -111,6 +111,8 @@ class Config:
|
||||
# Override default settings from environment
|
||||
self.default_recursion_depth = int(os.getenv('DEFAULT_RECURSION_DEPTH', '2'))
|
||||
self.flask_debug = os.getenv('FLASK_DEBUG', 'True').lower() == 'true'
|
||||
self.default_timeout = 30
|
||||
self.max_concurrent_requests = 5
|
||||
|
||||
|
||||
# Global configuration instance
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""
|
||||
Core modules for DNSRecon passive reconnaissance tool.
|
||||
Contains graph management, scanning orchestration, and forensic logging.
|
||||
Phase 2: Enhanced with concurrent processing and real-time capabilities.
|
||||
"""
|
||||
|
||||
from .graph_manager import GraphManager, NodeType, RelationshipType
|
||||
@ -19,4 +20,4 @@ __all__ = [
|
||||
'new_session'
|
||||
]
|
||||
|
||||
__version__ = "1.0.0-phase1"
|
||||
__version__ = "1.0.0-phase2"
|
@ -8,6 +8,7 @@ import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional, Tuple, Set
|
||||
from enum import Enum
|
||||
from datetime import timezone
|
||||
|
||||
import networkx as nx
|
||||
|
||||
@ -44,8 +45,8 @@ class GraphManager:
|
||||
def __init__(self):
|
||||
"""Initialize empty directed graph."""
|
||||
self.graph = nx.DiGraph()
|
||||
#self.lock = threading.Lock()
|
||||
self.creation_time = datetime.now(datetime.UTC).isoformat()
|
||||
# self.lock = threading.Lock()
|
||||
self.creation_time = datetime.now(timezone.utc).isoformat()
|
||||
self.last_modified = self.creation_time
|
||||
|
||||
def add_node(self, node_id: str, node_type: NodeType,
|
||||
@ -71,12 +72,12 @@ class GraphManager:
|
||||
|
||||
node_attributes = {
|
||||
'type': node_type.value,
|
||||
'added_timestamp': datetime.now(datetime.UTC).isoformat(),
|
||||
'added_timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'metadata': metadata or {}
|
||||
}
|
||||
|
||||
self.graph.add_node(node_id, **node_attributes)
|
||||
self.last_modified = datetime.now(datetime.UTC).isoformat()
|
||||
self.last_modified = datetime.now(timezone.utc).isoformat()
|
||||
return True
|
||||
|
||||
def add_edge(self, source_id: str, target_id: str,
|
||||
@ -111,7 +112,7 @@ class GraphManager:
|
||||
|
||||
if new_confidence > existing_confidence:
|
||||
self.graph.edges[source_id, target_id]['confidence_score'] = new_confidence
|
||||
self.graph.edges[source_id, target_id]['updated_timestamp'] = datetime.now(datetime.UTC).isoformat()
|
||||
self.graph.edges[source_id, target_id]['updated_timestamp'] = datetime.now(timezone.utc).isoformat()
|
||||
self.graph.edges[source_id, target_id]['updated_by'] = source_provider
|
||||
|
||||
return False
|
||||
@ -120,12 +121,12 @@ class GraphManager:
|
||||
'relationship_type': relationship_type.relationship_name,
|
||||
'confidence_score': confidence_score or relationship_type.default_confidence,
|
||||
'source_provider': source_provider,
|
||||
'discovery_timestamp': datetime.now(datetime.UTC).isoformat(),
|
||||
'discovery_timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'raw_data': raw_data or {}
|
||||
}
|
||||
|
||||
self.graph.add_edge(source_id, target_id, **edge_attributes)
|
||||
self.last_modified = datetime.now(datetime.UTC).isoformat()
|
||||
self.last_modified = datetime.now(timezone.utc).isoformat()
|
||||
return True
|
||||
|
||||
def get_node_count(self) -> int:
|
||||
@ -210,14 +211,36 @@ class GraphManager:
|
||||
'added_timestamp': attributes.get('added_timestamp')
|
||||
}
|
||||
|
||||
# Color coding by type
|
||||
# Color coding by type - now returns color objects for enhanced visualization
|
||||
type_colors = {
|
||||
'domain': '#00ff41', # Green for domains
|
||||
'ip': '#ff9900', # Amber for IPs
|
||||
'certificate': '#c7c7c7', # Gray for certificates
|
||||
'asn': '#00aaff' # Blue for ASNs
|
||||
'domain': {
|
||||
'background': '#00ff41',
|
||||
'border': '#00aa2e',
|
||||
'highlight': {'background': '#44ff75', 'border': '#00ff41'},
|
||||
'hover': {'background': '#22ff63', 'border': '#00cc35'}
|
||||
},
|
||||
'ip': {
|
||||
'background': '#ff9900',
|
||||
'border': '#cc7700',
|
||||
'highlight': {'background': '#ffbb44', 'border': '#ff9900'},
|
||||
'hover': {'background': '#ffaa22', 'border': '#dd8800'}
|
||||
},
|
||||
'certificate': {
|
||||
'background': '#c7c7c7',
|
||||
'border': '#999999',
|
||||
'highlight': {'background': '#e0e0e0', 'border': '#c7c7c7'},
|
||||
'hover': {'background': '#d4d4d4', 'border': '#aaaaaa'}
|
||||
},
|
||||
'asn': {
|
||||
'background': '#00aaff',
|
||||
'border': '#0088cc',
|
||||
'highlight': {'background': '#44ccff', 'border': '#00aaff'},
|
||||
'hover': {'background': '#22bbff', 'border': '#0099dd'}
|
||||
}
|
||||
node_data['color'] = type_colors.get(attributes.get('type'), '#ffffff')
|
||||
}
|
||||
|
||||
node_color_config = type_colors.get(attributes.get('type', 'unknown'), type_colors['domain'])
|
||||
node_data['color'] = node_color_config
|
||||
nodes.append(node_data)
|
||||
|
||||
# Format edges for visualization
|
||||
@ -231,17 +254,36 @@ class GraphManager:
|
||||
'discovery_timestamp': attributes.get('discovery_timestamp')
|
||||
}
|
||||
|
||||
# Edge styling based on confidence
|
||||
# Enhanced edge styling based on confidence
|
||||
confidence = attributes.get('confidence_score', 0)
|
||||
if confidence >= 0.8:
|
||||
edge_data['color'] = '#00ff41' # Green for high confidence
|
||||
edge_data['width'] = 3
|
||||
edge_data['color'] = {
|
||||
'color': '#00ff41',
|
||||
'highlight': '#44ff75',
|
||||
'hover': '#22ff63',
|
||||
'inherit': False
|
||||
}
|
||||
edge_data['width'] = 4
|
||||
elif confidence >= 0.6:
|
||||
edge_data['color'] = '#ff9900' # Amber for medium confidence
|
||||
edge_data['width'] = 2
|
||||
edge_data['color'] = {
|
||||
'color': '#ff9900',
|
||||
'highlight': '#ffbb44',
|
||||
'hover': '#ffaa22',
|
||||
'inherit': False
|
||||
}
|
||||
edge_data['width'] = 3
|
||||
else:
|
||||
edge_data['color'] = '#444444' # Dark gray for low confidence
|
||||
edge_data['width'] = 1
|
||||
edge_data['color'] = {
|
||||
'color': '#666666',
|
||||
'highlight': '#888888',
|
||||
'hover': '#777777',
|
||||
'inherit': False
|
||||
}
|
||||
edge_data['width'] = 2
|
||||
|
||||
# Add dashed line for low confidence
|
||||
if confidence < 0.6:
|
||||
edge_data['dashes'] = [5, 5]
|
||||
|
||||
edges.append(edge_data)
|
||||
|
||||
@ -270,7 +312,7 @@ class GraphManager:
|
||||
# Add comprehensive metadata
|
||||
export_data = {
|
||||
'export_metadata': {
|
||||
'export_timestamp': datetime.now(datetime.UTC).isoformat(),
|
||||
'export_timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'graph_creation_time': self.creation_time,
|
||||
'last_modified': self.last_modified,
|
||||
'total_nodes': self.graph.number_of_nodes(),
|
||||
@ -351,5 +393,5 @@ class GraphManager:
|
||||
"""Clear all nodes and edges from the graph."""
|
||||
#with self.lock:
|
||||
self.graph.clear()
|
||||
self.creation_time = datetime.now(datetime.UTC).isoformat()
|
||||
self.creation_time = datetime.now(timezone.utc).isoformat()
|
||||
self.last_modified = self.creation_time
|
@ -9,6 +9,7 @@ import threading
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional, List
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import timezone
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -60,7 +61,7 @@ class ForensicLogger:
|
||||
self.relationships: List[RelationshipDiscovery] = []
|
||||
self.session_metadata = {
|
||||
'session_id': self.session_id,
|
||||
'start_time': datetime.now(datetime.UTC).isoformat(),
|
||||
'start_time': datetime.now(timezone.utc).isoformat(),
|
||||
'end_time': None,
|
||||
'total_requests': 0,
|
||||
'total_relationships': 0,
|
||||
@ -85,7 +86,7 @@ class ForensicLogger:
|
||||
|
||||
def _generate_session_id(self) -> str:
|
||||
"""Generate unique session identifier."""
|
||||
return f"dnsrecon_{datetime.now(datetime.UTC).strftime('%Y%m%d_%H%M%S')}"
|
||||
return f"dnsrecon_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}"
|
||||
|
||||
def log_api_request(self, provider: str, url: str, method: str = "GET",
|
||||
status_code: Optional[int] = None,
|
||||
@ -110,7 +111,7 @@ class ForensicLogger:
|
||||
"""
|
||||
#with self.lock:
|
||||
api_request = APIRequest(
|
||||
timestamp=datetime.now(datetime.UTC).isoformat(),
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
provider=provider,
|
||||
url=url,
|
||||
method=method,
|
||||
@ -153,7 +154,7 @@ class ForensicLogger:
|
||||
"""
|
||||
#with self.lock:
|
||||
relationship = RelationshipDiscovery(
|
||||
timestamp=datetime.now(datetime.UTC).isoformat(),
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
source_node=source_node,
|
||||
target_node=target_node,
|
||||
relationship_type=relationship_type,
|
||||
@ -183,7 +184,7 @@ class ForensicLogger:
|
||||
def log_scan_complete(self) -> None:
|
||||
"""Log the completion of a reconnaissance scan."""
|
||||
#with self.lock:
|
||||
self.session_metadata['end_time'] = datetime.now(datetime.UTC).isoformat()
|
||||
self.session_metadata['end_time'] = datetime.now(timezone.utc).isoformat()
|
||||
self.session_metadata['providers_used'] = list(self.session_metadata['providers_used'])
|
||||
self.session_metadata['target_domains'] = list(self.session_metadata['target_domains'])
|
||||
|
||||
@ -203,7 +204,7 @@ class ForensicLogger:
|
||||
'session_metadata': self.session_metadata.copy(),
|
||||
'api_requests': [asdict(req) for req in self.api_requests],
|
||||
'relationships': [asdict(rel) for rel in self.relationships],
|
||||
'export_timestamp': datetime.now(datetime.UTC).isoformat()
|
||||
'export_timestamp': datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
def get_forensic_summary(self) -> Dict[str, Any]:
|
||||
@ -239,7 +240,7 @@ class ForensicLogger:
|
||||
def _calculate_session_duration(self) -> float:
|
||||
"""Calculate session duration in minutes."""
|
||||
if not self.session_metadata['end_time']:
|
||||
end_time = datetime.now(datetime.UTC)
|
||||
end_time = datetime.now(timezone.utc)
|
||||
else:
|
||||
end_time = datetime.fromisoformat(self.session_metadata['end_time'])
|
||||
|
||||
|
554
core/scanner.py
554
core/scanner.py
@ -6,12 +6,15 @@ Coordinates data gathering from multiple providers and builds the infrastructure
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from typing import List, Set, Dict, Any, Optional
|
||||
from typing import List, Set, Dict, Any, Optional, Tuple
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from core.graph_manager import GraphManager, NodeType, RelationshipType
|
||||
from core.logger import get_forensic_logger, new_session
|
||||
from providers.crtsh_provider import CrtShProvider
|
||||
from providers.dns_provider import DNSProvider
|
||||
from providers.shodan_provider import ShodanProvider
|
||||
from providers.virustotal_provider import VirusTotalProvider
|
||||
from config import config
|
||||
|
||||
|
||||
@ -27,17 +30,16 @@ class ScanStatus:
|
||||
class Scanner:
|
||||
"""
|
||||
Main scanning orchestrator for DNSRecon passive reconnaissance.
|
||||
Manages multi-provider data gathering and graph construction.
|
||||
Manages multi-provider data gathering and graph construction with concurrent processing.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize scanner with default providers and empty graph."""
|
||||
"""Initialize scanner with all available providers and empty graph."""
|
||||
print("Initializing Scanner instance...")
|
||||
|
||||
try:
|
||||
from providers.base_provider import BaseProvider
|
||||
self.graph = GraphManager()
|
||||
self.providers: List[BaseProvider] = []
|
||||
self.providers = []
|
||||
self.status = ScanStatus.IDLE
|
||||
self.current_target = None
|
||||
self.current_depth = 0
|
||||
@ -50,6 +52,9 @@ class Scanner:
|
||||
self.indicators_processed = 0
|
||||
self.current_indicator = ""
|
||||
|
||||
# Concurrent processing configuration
|
||||
self.max_workers = config.max_concurrent_requests
|
||||
|
||||
# Initialize providers
|
||||
print("Calling _initialize_providers...")
|
||||
self._initialize_providers()
|
||||
@ -66,36 +71,54 @@ class Scanner:
|
||||
raise
|
||||
|
||||
def _initialize_providers(self) -> None:
|
||||
"""Initialize available providers based on configuration."""
|
||||
"""Initialize all available providers based on configuration."""
|
||||
self.providers = []
|
||||
|
||||
print("Initializing providers...")
|
||||
|
||||
# Always add free providers
|
||||
if config.is_provider_enabled('crtsh'):
|
||||
free_providers = [
|
||||
('crtsh', CrtShProvider),
|
||||
('dns', DNSProvider)
|
||||
]
|
||||
|
||||
for provider_name, provider_class in free_providers:
|
||||
if config.is_provider_enabled(provider_name):
|
||||
try:
|
||||
crtsh_provider = CrtShProvider()
|
||||
if crtsh_provider.is_available():
|
||||
self.providers.append(crtsh_provider)
|
||||
print("✓ CrtSh provider initialized successfully")
|
||||
provider = provider_class()
|
||||
if provider.is_available():
|
||||
self.providers.append(provider)
|
||||
print(f"✓ {provider_name.title()} provider initialized successfully")
|
||||
else:
|
||||
print("✗ CrtSh provider is not available")
|
||||
print(f"✗ {provider_name.title()} provider is not available")
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to initialize CrtSh provider: {e}")
|
||||
print(f"✗ Failed to initialize {provider_name.title()} provider: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# Add API key-dependent providers
|
||||
api_providers = [
|
||||
('shodan', ShodanProvider),
|
||||
('virustotal', VirusTotalProvider)
|
||||
]
|
||||
|
||||
for provider_name, provider_class in api_providers:
|
||||
if config.is_provider_enabled(provider_name):
|
||||
try:
|
||||
provider = provider_class()
|
||||
if provider.is_available():
|
||||
self.providers.append(provider)
|
||||
print(f"✓ {provider_name.title()} provider initialized successfully")
|
||||
else:
|
||||
print(f"✗ {provider_name.title()} provider is not available (API key required)")
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to initialize {provider_name.title()} provider: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
print(f"Initialized {len(self.providers)} providers")
|
||||
|
||||
def _debug_threads(self):
|
||||
"""Debug function to show current threads."""
|
||||
print("=== THREAD DEBUG INFO ===")
|
||||
for t in threading.enumerate():
|
||||
print(f"Thread: {t.name} | Alive: {t.is_alive()} | Daemon: {t.daemon}")
|
||||
print("=== END THREAD DEBUG ===")
|
||||
|
||||
def start_scan(self, target_domain: str, max_depth: int = 2) -> bool:
|
||||
"""
|
||||
Start a new reconnaissance scan.
|
||||
Start a new reconnaissance scan with concurrent processing.
|
||||
|
||||
Args:
|
||||
target_domain: Initial domain to investigate
|
||||
@ -107,9 +130,6 @@ class Scanner:
|
||||
print(f"Scanner.start_scan called with target='{target_domain}', depth={max_depth}")
|
||||
|
||||
try:
|
||||
print("Checking current status...")
|
||||
self._debug_threads()
|
||||
|
||||
if self.status == ScanStatus.RUNNING:
|
||||
print("Scan already running, rejecting new scan")
|
||||
return False
|
||||
@ -119,8 +139,6 @@ class Scanner:
|
||||
print("No providers available, cannot start scan")
|
||||
return False
|
||||
|
||||
print(f"Current status: {self.status}, Providers: {len(self.providers)}")
|
||||
|
||||
# Stop any existing scan thread
|
||||
if self.scan_thread and self.scan_thread.is_alive():
|
||||
print("Stopping existing scan thread...")
|
||||
@ -132,9 +150,7 @@ class Scanner:
|
||||
|
||||
# Reset state
|
||||
print("Resetting scanner state...")
|
||||
#print("Running graph.clear()")
|
||||
#self.graph.clear()
|
||||
print("running self.current_target = target_domain.lower().strip()")
|
||||
self.graph.clear()
|
||||
self.current_target = target_domain.lower().strip()
|
||||
self.max_depth = max_depth
|
||||
self.current_depth = 0
|
||||
@ -147,9 +163,15 @@ class Scanner:
|
||||
print("Starting new forensic session...")
|
||||
self.logger = new_session()
|
||||
|
||||
# FOR DEBUGGING: Run scan synchronously instead of in thread
|
||||
print("Running scan synchronously for debugging...")
|
||||
self._execute_scan_sync(self.current_target, max_depth)
|
||||
# Start scan in separate thread for Phase 2
|
||||
print("Starting scan thread...")
|
||||
self.scan_thread = threading.Thread(
|
||||
target=self._execute_scan_async,
|
||||
args=(self.current_target, max_depth),
|
||||
daemon=True
|
||||
)
|
||||
self.scan_thread.start()
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@ -157,6 +179,321 @@ class Scanner:
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
def _execute_scan_async(self, target_domain: str, max_depth: int) -> None:
|
||||
"""
|
||||
Execute the reconnaissance scan asynchronously with concurrent provider queries.
|
||||
|
||||
Args:
|
||||
target_domain: Target domain to investigate
|
||||
max_depth: Maximum recursion depth
|
||||
"""
|
||||
print(f"_execute_scan_async started for {target_domain} with depth {max_depth}")
|
||||
|
||||
try:
|
||||
print("Setting status to RUNNING")
|
||||
self.status = ScanStatus.RUNNING
|
||||
|
||||
# Log scan start
|
||||
enabled_providers = [provider.get_name() for provider in self.providers]
|
||||
self.logger.log_scan_start(target_domain, max_depth, enabled_providers)
|
||||
print(f"Logged scan start with providers: {enabled_providers}")
|
||||
|
||||
# Initialize with target domain
|
||||
print(f"Adding target domain '{target_domain}' as initial node")
|
||||
self.graph.add_node(target_domain, NodeType.DOMAIN)
|
||||
|
||||
# BFS-style exploration with depth limiting and concurrent processing
|
||||
current_level_domains = {target_domain}
|
||||
processed_domains = set()
|
||||
all_discovered_ips = set()
|
||||
|
||||
print(f"Starting BFS exploration...")
|
||||
|
||||
for depth in range(max_depth + 1):
|
||||
if self.stop_requested:
|
||||
print(f"Stop requested at depth {depth}")
|
||||
break
|
||||
|
||||
self.current_depth = depth
|
||||
print(f"Processing depth level {depth} with {len(current_level_domains)} domains")
|
||||
|
||||
if not current_level_domains:
|
||||
print("No domains to process at this level")
|
||||
break
|
||||
|
||||
# Update progress tracking
|
||||
self.total_indicators_found += len(current_level_domains)
|
||||
next_level_domains = set()
|
||||
|
||||
# Process domains at current depth level with concurrent queries
|
||||
domain_results = self._process_domains_concurrent(current_level_domains, processed_domains)
|
||||
|
||||
for domain, discovered_domains, discovered_ips in domain_results:
|
||||
if self.stop_requested:
|
||||
break
|
||||
|
||||
processed_domains.add(domain)
|
||||
all_discovered_ips.update(discovered_ips)
|
||||
|
||||
# Add discovered domains to next level if not at max depth
|
||||
if depth < max_depth:
|
||||
for discovered_domain in discovered_domains:
|
||||
if discovered_domain not in processed_domains:
|
||||
next_level_domains.add(discovered_domain)
|
||||
print(f"Adding {discovered_domain} to next level")
|
||||
|
||||
# Process discovered IPs concurrently
|
||||
if all_discovered_ips:
|
||||
print(f"Processing {len(all_discovered_ips)} discovered IP addresses")
|
||||
self._process_ips_concurrent(all_discovered_ips)
|
||||
|
||||
current_level_domains = next_level_domains
|
||||
print(f"Completed depth {depth}, {len(next_level_domains)} domains for next level")
|
||||
|
||||
# Finalize scan
|
||||
if self.stop_requested:
|
||||
self.status = ScanStatus.STOPPED
|
||||
print("Scan completed with STOPPED status")
|
||||
else:
|
||||
self.status = ScanStatus.COMPLETED
|
||||
print("Scan completed with COMPLETED status")
|
||||
|
||||
self.logger.log_scan_complete()
|
||||
|
||||
# Print final statistics
|
||||
stats = self.graph.get_statistics()
|
||||
print(f"Final scan statistics:")
|
||||
print(f" - Total nodes: {stats['basic_metrics']['total_nodes']}")
|
||||
print(f" - Total edges: {stats['basic_metrics']['total_edges']}")
|
||||
print(f" - Domains processed: {len(processed_domains)}")
|
||||
print(f" - IPs discovered: {len(all_discovered_ips)}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Scan execution failed with error: {e}")
|
||||
traceback.print_exc()
|
||||
self.status = ScanStatus.FAILED
|
||||
self.logger.logger.error(f"Scan failed: {e}")
|
||||
|
||||
def _process_domains_concurrent(self, domains: Set[str], processed_domains: Set[str]) -> List[Tuple[str, Set[str], Set[str]]]:
|
||||
"""
|
||||
Process multiple domains concurrently using thread pool.
|
||||
|
||||
Args:
|
||||
domains: Set of domains to process
|
||||
processed_domains: Set of already processed domains
|
||||
|
||||
Returns:
|
||||
List of tuples (domain, discovered_domains, discovered_ips)
|
||||
"""
|
||||
results = []
|
||||
|
||||
# Filter out already processed domains
|
||||
domains_to_process = domains - processed_domains
|
||||
|
||||
if not domains_to_process:
|
||||
return results
|
||||
|
||||
print(f"Processing {len(domains_to_process)} domains concurrently with {self.max_workers} workers")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
# Submit all domain processing tasks
|
||||
future_to_domain = {
|
||||
executor.submit(self._query_providers_for_domain, domain): domain
|
||||
for domain in domains_to_process
|
||||
}
|
||||
|
||||
# Collect results as they complete
|
||||
for future in as_completed(future_to_domain):
|
||||
if self.stop_requested:
|
||||
break
|
||||
|
||||
domain = future_to_domain[future]
|
||||
|
||||
try:
|
||||
discovered_domains, discovered_ips = future.result()
|
||||
results.append((domain, discovered_domains, discovered_ips))
|
||||
self.indicators_processed += 1
|
||||
print(f"Completed processing domain: {domain} ({len(discovered_domains)} domains, {len(discovered_ips)} IPs)")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing domain {domain}: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
return results
|
||||
|
||||
def _process_ips_concurrent(self, ips: Set[str]) -> None:
|
||||
"""
|
||||
Process multiple IP addresses concurrently.
|
||||
|
||||
Args:
|
||||
ips: Set of IP addresses to process
|
||||
"""
|
||||
if not ips:
|
||||
return
|
||||
|
||||
print(f"Processing {len(ips)} IP addresses concurrently")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
# Submit all IP processing tasks
|
||||
future_to_ip = {
|
||||
executor.submit(self._query_providers_for_ip, ip): ip
|
||||
for ip in ips
|
||||
}
|
||||
|
||||
# Collect results as they complete
|
||||
for future in as_completed(future_to_ip):
|
||||
if self.stop_requested:
|
||||
break
|
||||
|
||||
ip = future_to_ip[future]
|
||||
|
||||
try:
|
||||
future.result() # Just wait for completion
|
||||
print(f"Completed processing IP: {ip}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing IP {ip}: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
def _query_providers_for_domain(self, domain: str) -> Tuple[Set[str], Set[str]]:
|
||||
"""
|
||||
Query all enabled providers for information about a domain.
|
||||
|
||||
Args:
|
||||
domain: Domain to investigate
|
||||
|
||||
Returns:
|
||||
Tuple of (discovered_domains, discovered_ips)
|
||||
"""
|
||||
print(f"Querying {len(self.providers)} providers for domain: {domain}")
|
||||
discovered_domains = set()
|
||||
discovered_ips = set()
|
||||
|
||||
if not self.providers:
|
||||
print("No providers available")
|
||||
return discovered_domains, discovered_ips
|
||||
|
||||
# Query providers concurrently for better performance
|
||||
with ThreadPoolExecutor(max_workers=len(self.providers)) as executor:
|
||||
# Submit queries for all providers
|
||||
future_to_provider = {
|
||||
executor.submit(self._safe_provider_query_domain, provider, domain): provider
|
||||
for provider in self.providers
|
||||
}
|
||||
|
||||
# Collect results as they complete
|
||||
for future in as_completed(future_to_provider):
|
||||
if self.stop_requested:
|
||||
break
|
||||
|
||||
provider = future_to_provider[future]
|
||||
|
||||
try:
|
||||
relationships = future.result()
|
||||
print(f"Provider {provider.get_name()} returned {len(relationships)} relationships")
|
||||
|
||||
for source, target, rel_type, confidence, raw_data in relationships:
|
||||
# Determine node type based on target
|
||||
if self._is_valid_ip(target):
|
||||
target_node_type = NodeType.IP
|
||||
discovered_ips.add(target)
|
||||
elif self._is_valid_domain(target):
|
||||
target_node_type = NodeType.DOMAIN
|
||||
discovered_domains.add(target)
|
||||
else:
|
||||
# Could be ASN or certificate
|
||||
target_node_type = NodeType.ASN if target.startswith('AS') else NodeType.CERTIFICATE
|
||||
|
||||
# Add nodes and relationship to graph
|
||||
self.graph.add_node(source, NodeType.DOMAIN)
|
||||
self.graph.add_node(target, target_node_type)
|
||||
|
||||
success = self.graph.add_edge(
|
||||
source, target, rel_type, confidence,
|
||||
provider.get_name(), raw_data
|
||||
)
|
||||
|
||||
if success:
|
||||
print(f"Added relationship: {source} -> {target} ({rel_type.relationship_name})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Provider {provider.get_name()} failed for {domain}: {e}")
|
||||
|
||||
print(f"Domain {domain}: discovered {len(discovered_domains)} domains, {len(discovered_ips)} IPs")
|
||||
return discovered_domains, discovered_ips
|
||||
|
||||
def _query_providers_for_ip(self, ip: str) -> None:
|
||||
"""
|
||||
Query all enabled providers for information about an IP address.
|
||||
|
||||
Args:
|
||||
ip: IP address to investigate
|
||||
"""
|
||||
print(f"Querying {len(self.providers)} providers for IP: {ip}")
|
||||
|
||||
if not self.providers:
|
||||
print("No providers available")
|
||||
return
|
||||
|
||||
# Query providers concurrently
|
||||
with ThreadPoolExecutor(max_workers=len(self.providers)) as executor:
|
||||
# Submit queries for all providers
|
||||
future_to_provider = {
|
||||
executor.submit(self._safe_provider_query_ip, provider, ip): provider
|
||||
for provider in self.providers
|
||||
}
|
||||
|
||||
# Collect results as they complete
|
||||
for future in as_completed(future_to_provider):
|
||||
if self.stop_requested:
|
||||
break
|
||||
|
||||
provider = future_to_provider[future]
|
||||
|
||||
try:
|
||||
relationships = future.result()
|
||||
print(f"Provider {provider.get_name()} returned {len(relationships)} relationships for IP {ip}")
|
||||
|
||||
for source, target, rel_type, confidence, raw_data in relationships:
|
||||
# Determine node type based on target
|
||||
if self._is_valid_domain(target):
|
||||
target_node_type = NodeType.DOMAIN
|
||||
elif target.startswith('AS'):
|
||||
target_node_type = NodeType.ASN
|
||||
else:
|
||||
target_node_type = NodeType.IP
|
||||
|
||||
# Add nodes and relationship to graph
|
||||
self.graph.add_node(source, NodeType.IP)
|
||||
self.graph.add_node(target, target_node_type)
|
||||
|
||||
success = self.graph.add_edge(
|
||||
source, target, rel_type, confidence,
|
||||
provider.get_name(), raw_data
|
||||
)
|
||||
|
||||
if success:
|
||||
print(f"Added IP relationship: {source} -> {target} ({rel_type.relationship_name})")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Provider {provider.get_name()} failed for IP {ip}: {e}")
|
||||
|
||||
def _safe_provider_query_domain(self, provider, domain: str):
|
||||
"""Safely query provider for domain with error handling."""
|
||||
try:
|
||||
return provider.query_domain(domain)
|
||||
except Exception as e:
|
||||
print(f"Provider {provider.get_name()} query_domain failed: {e}")
|
||||
return []
|
||||
|
||||
def _safe_provider_query_ip(self, provider, ip: str):
|
||||
"""Safely query provider for IP with error handling."""
|
||||
try:
|
||||
return provider.query_ip(ip)
|
||||
except Exception as e:
|
||||
print(f"Provider {provider.get_name()} query_ip failed: {e}")
|
||||
return []
|
||||
|
||||
def stop_scan(self) -> bool:
|
||||
"""
|
||||
Request scan termination.
|
||||
@ -218,159 +555,6 @@ class Scanner:
|
||||
return 0.0
|
||||
return min(100.0, (self.indicators_processed / self.total_indicators_found) * 100)
|
||||
|
||||
def _execute_scan_sync(self, target_domain: str, max_depth: int) -> None:
|
||||
"""
|
||||
Execute the reconnaissance scan synchronously (for debugging).
|
||||
|
||||
Args:
|
||||
target_domain: Target domain to investigate
|
||||
max_depth: Maximum recursion depth
|
||||
"""
|
||||
print(f"_execute_scan_sync started for {target_domain} with depth {max_depth}")
|
||||
|
||||
try:
|
||||
print("Setting status to RUNNING")
|
||||
self.status = ScanStatus.RUNNING
|
||||
|
||||
# Log scan start
|
||||
enabled_providers = [provider.get_name() for provider in self.providers]
|
||||
self.logger.log_scan_start(target_domain, max_depth, enabled_providers)
|
||||
print(f"Logged scan start with providers: {enabled_providers}")
|
||||
|
||||
# Initialize with target domain
|
||||
print(f"Adding target domain '{target_domain}' as initial node")
|
||||
self.graph.add_node(target_domain, NodeType.DOMAIN)
|
||||
|
||||
# BFS-style exploration with depth limiting
|
||||
current_level_domains = {target_domain}
|
||||
processed_domains = set()
|
||||
|
||||
print(f"Starting BFS exploration...")
|
||||
|
||||
for depth in range(max_depth + 1):
|
||||
if self.stop_requested:
|
||||
print(f"Stop requested at depth {depth}")
|
||||
break
|
||||
|
||||
self.current_depth = depth
|
||||
print(f"Processing depth level {depth} with {len(current_level_domains)} domains")
|
||||
|
||||
if not current_level_domains:
|
||||
print("No domains to process at this level")
|
||||
break
|
||||
|
||||
# Update progress tracking
|
||||
self.total_indicators_found += len(current_level_domains)
|
||||
next_level_domains = set()
|
||||
|
||||
# Process domains at current depth level
|
||||
for domain in current_level_domains:
|
||||
if self.stop_requested:
|
||||
print(f"Stop requested while processing domain {domain}")
|
||||
break
|
||||
|
||||
if domain in processed_domains:
|
||||
print(f"Domain {domain} already processed, skipping")
|
||||
continue
|
||||
|
||||
print(f"Processing domain: {domain}")
|
||||
self.current_indicator = domain
|
||||
self.indicators_processed += 1
|
||||
|
||||
# Query all providers for this domain
|
||||
discovered_domains = self._query_providers_for_domain(domain)
|
||||
print(f"Discovered {len(discovered_domains)} new domains from {domain}")
|
||||
|
||||
# Add discovered domains to next level if not at max depth
|
||||
if depth < max_depth:
|
||||
for discovered_domain in discovered_domains:
|
||||
if discovered_domain not in processed_domains:
|
||||
next_level_domains.add(discovered_domain)
|
||||
print(f"Adding {discovered_domain} to next level")
|
||||
|
||||
processed_domains.add(domain)
|
||||
|
||||
current_level_domains = next_level_domains
|
||||
print(f"Completed depth {depth}, {len(next_level_domains)} domains for next level")
|
||||
|
||||
# Finalize scan
|
||||
if self.stop_requested:
|
||||
self.status = ScanStatus.STOPPED
|
||||
print("Scan completed with STOPPED status")
|
||||
else:
|
||||
self.status = ScanStatus.COMPLETED
|
||||
print("Scan completed with COMPLETED status")
|
||||
|
||||
self.logger.log_scan_complete()
|
||||
|
||||
# Print final statistics
|
||||
stats = self.graph.get_statistics()
|
||||
print(f"Final scan statistics:")
|
||||
print(f" - Total nodes: {stats['basic_metrics']['total_nodes']}")
|
||||
print(f" - Total edges: {stats['basic_metrics']['total_edges']}")
|
||||
print(f" - Domains processed: {len(processed_domains)}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Scan execution failed with error: {e}")
|
||||
traceback.print_exc()
|
||||
self.status = ScanStatus.FAILED
|
||||
self.logger.logger.error(f"Scan failed: {e}")
|
||||
|
||||
def _query_providers_for_domain(self, domain: str) -> Set[str]:
|
||||
"""
|
||||
Query all enabled providers for information about a domain.
|
||||
|
||||
Args:
|
||||
domain: Domain to investigate
|
||||
|
||||
Returns:
|
||||
Set of newly discovered domains
|
||||
"""
|
||||
print(f"Querying {len(self.providers)} providers for domain: {domain}")
|
||||
discovered_domains = set()
|
||||
|
||||
if not self.providers:
|
||||
print("No providers available")
|
||||
return discovered_domains
|
||||
|
||||
# Query providers sequentially for debugging
|
||||
for provider in self.providers:
|
||||
if self.stop_requested:
|
||||
print("Stop requested, cancelling provider queries")
|
||||
break
|
||||
|
||||
try:
|
||||
print(f"Querying provider: {provider.get_name()}")
|
||||
relationships = provider.query_domain(domain)
|
||||
print(f"Provider {provider.get_name()} returned {len(relationships)} relationships")
|
||||
|
||||
for source, target, rel_type, confidence, raw_data in relationships:
|
||||
print(f"Processing relationship: {source} -> {target} ({rel_type.relationship_name})")
|
||||
|
||||
# Add target node to graph if it doesn't exist
|
||||
self.graph.add_node(target, NodeType.DOMAIN)
|
||||
|
||||
# Add relationship
|
||||
success = self.graph.add_edge(
|
||||
source, target, rel_type, confidence,
|
||||
provider.get_name(), raw_data
|
||||
)
|
||||
|
||||
if success:
|
||||
print(f"Added new relationship: {source} -> {target}")
|
||||
else:
|
||||
print(f"Relationship already exists or failed to add: {source} -> {target}")
|
||||
|
||||
discovered_domains.add(target)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Provider {provider.get_name()} failed for {domain}: {e}")
|
||||
traceback.print_exc()
|
||||
self.logger.logger.error(f"Provider {provider.get_name()} failed for {domain}: {e}")
|
||||
|
||||
print(f"Total unique domains discovered: {len(discovered_domains)}")
|
||||
return discovered_domains
|
||||
|
||||
def get_graph_data(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current graph data for visualization.
|
||||
|
@ -5,11 +5,17 @@ Contains implementations for various reconnaissance data sources.
|
||||
|
||||
from .base_provider import BaseProvider, RateLimiter
|
||||
from .crtsh_provider import CrtShProvider
|
||||
from .dns_provider import DNSProvider
|
||||
from .shodan_provider import ShodanProvider
|
||||
from .virustotal_provider import VirusTotalProvider
|
||||
|
||||
__all__ = [
|
||||
'BaseProvider',
|
||||
'RateLimiter',
|
||||
'CrtShProvider'
|
||||
'CrtShProvider',
|
||||
'DNSProvider',
|
||||
'ShodanProvider',
|
||||
'VirusTotalProvider'
|
||||
]
|
||||
|
||||
__version__ = "1.0.0-phase1"
|
||||
__version__ = "1.0.0-phase2"
|
@ -115,9 +115,10 @@ class BaseProvider(ABC):
|
||||
def make_request(self, url: str, method: str = "GET",
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
target_indicator: str = "") -> Optional[requests.Response]:
|
||||
target_indicator: str = "",
|
||||
max_retries: int = 3) -> Optional[requests.Response]:
|
||||
"""
|
||||
Make a rate-limited HTTP request with forensic logging.
|
||||
Make a rate-limited HTTP request with forensic logging and retry logic.
|
||||
|
||||
Args:
|
||||
url: Request URL
|
||||
@ -125,10 +126,12 @@ class BaseProvider(ABC):
|
||||
params: Query parameters
|
||||
headers: Additional headers
|
||||
target_indicator: The indicator being investigated
|
||||
max_retries: Maximum number of retry attempts
|
||||
|
||||
Returns:
|
||||
Response object or None if request failed
|
||||
"""
|
||||
for attempt in range(max_retries + 1):
|
||||
# Apply rate limiting
|
||||
self.rate_limiter.wait_if_needed()
|
||||
|
||||
@ -144,7 +147,7 @@ class BaseProvider(ABC):
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
|
||||
print(f"Making {method} request to: {url}")
|
||||
print(f"Making {method} request to: {url} (attempt {attempt + 1})")
|
||||
|
||||
# Make request
|
||||
if method.upper() == "GET":
|
||||
@ -168,19 +171,42 @@ class BaseProvider(ABC):
|
||||
response.raise_for_status()
|
||||
self.successful_requests += 1
|
||||
|
||||
# Success - log and return
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
self.logger.log_api_request(
|
||||
provider=self.name,
|
||||
url=url,
|
||||
method=method.upper(),
|
||||
status_code=response.status_code,
|
||||
response_size=len(response.content),
|
||||
duration_ms=duration_ms,
|
||||
error=None,
|
||||
target_indicator=target_indicator
|
||||
)
|
||||
return response
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error = str(e)
|
||||
self.failed_requests += 1
|
||||
print(f"Request failed: {error}")
|
||||
print(f"Request failed (attempt {attempt + 1}): {error}")
|
||||
|
||||
# Check if we should retry
|
||||
if attempt < max_retries and self._should_retry(e):
|
||||
backoff_time = (2 ** attempt) * 1 # Exponential backoff: 1s, 2s, 4s
|
||||
print(f"Retrying in {backoff_time} seconds...")
|
||||
time.sleep(backoff_time)
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
error = f"Unexpected error: {str(e)}"
|
||||
self.failed_requests += 1
|
||||
print(f"Unexpected error: {error}")
|
||||
break
|
||||
|
||||
# Calculate duration and log request
|
||||
# All attempts failed - log and return None
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
|
||||
self.logger.log_api_request(
|
||||
provider=self.name,
|
||||
url=url,
|
||||
@ -192,7 +218,29 @@ class BaseProvider(ABC):
|
||||
target_indicator=target_indicator
|
||||
)
|
||||
|
||||
return response if error is None else None
|
||||
return None
|
||||
|
||||
def _should_retry(self, exception: requests.exceptions.RequestException) -> bool:
|
||||
"""
|
||||
Determine if a request should be retried based on the exception.
|
||||
|
||||
Args:
|
||||
exception: The request exception that occurred
|
||||
|
||||
Returns:
|
||||
True if the request should be retried
|
||||
"""
|
||||
# Retry on connection errors, timeouts, and 5xx server errors
|
||||
if isinstance(exception, (requests.exceptions.ConnectionError,
|
||||
requests.exceptions.Timeout)):
|
||||
return True
|
||||
|
||||
if isinstance(exception, requests.exceptions.HTTPError):
|
||||
if hasattr(exception, 'response') and exception.response:
|
||||
# Retry on server errors (5xx) but not client errors (4xx)
|
||||
return exception.response.status_code >= 500
|
||||
|
||||
return False
|
||||
|
||||
def log_relationship_discovery(self, source_node: str, target_node: str,
|
||||
relationship_type: RelationshipType,
|
||||
|
@ -0,0 +1,338 @@
|
||||
"""
|
||||
DNS resolution provider for DNSRecon.
|
||||
Discovers domain relationships through DNS record analysis.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import dns.resolver
|
||||
import dns.reversename
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from .base_provider import BaseProvider
|
||||
from core.graph_manager import RelationshipType, NodeType
|
||||
|
||||
|
||||
class DNSProvider(BaseProvider):
|
||||
"""
|
||||
Provider for standard DNS resolution and reverse DNS lookups.
|
||||
Discovers domain-to-IP and IP-to-domain relationships through DNS records.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize DNS provider with appropriate rate limiting."""
|
||||
super().__init__(
|
||||
name="dns",
|
||||
rate_limit=100, # DNS queries can be faster
|
||||
timeout=10
|
||||
)
|
||||
|
||||
# Configure DNS resolver
|
||||
self.resolver = dns.resolver.Resolver()
|
||||
self.resolver.timeout = 5
|
||||
self.resolver.lifetime = 10
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Return the provider name."""
|
||||
return "dns"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""DNS is always available - no API key required."""
|
||||
return True
|
||||
|
||||
def query_domain(self, domain: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""
|
||||
Query DNS records for the domain to discover relationships.
|
||||
|
||||
Args:
|
||||
domain: Domain to investigate
|
||||
|
||||
Returns:
|
||||
List of relationships discovered from DNS analysis
|
||||
"""
|
||||
if not self._is_valid_domain(domain):
|
||||
return []
|
||||
|
||||
relationships = []
|
||||
|
||||
# Query A records
|
||||
relationships.extend(self._query_a_records(domain))
|
||||
|
||||
# Query AAAA records (IPv6)
|
||||
relationships.extend(self._query_aaaa_records(domain))
|
||||
|
||||
# Query CNAME records
|
||||
relationships.extend(self._query_cname_records(domain))
|
||||
|
||||
# Query MX records
|
||||
relationships.extend(self._query_mx_records(domain))
|
||||
|
||||
# Query NS records
|
||||
relationships.extend(self._query_ns_records(domain))
|
||||
|
||||
return relationships
|
||||
|
||||
def query_ip(self, ip: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""
|
||||
Query reverse DNS for the IP address.
|
||||
|
||||
Args:
|
||||
ip: IP address to investigate
|
||||
|
||||
Returns:
|
||||
List of relationships discovered from reverse DNS
|
||||
"""
|
||||
if not self._is_valid_ip(ip):
|
||||
return []
|
||||
|
||||
relationships = []
|
||||
|
||||
try:
|
||||
# Perform reverse DNS lookup
|
||||
reverse_name = dns.reversename.from_address(ip)
|
||||
response = self.resolver.resolve(reverse_name, 'PTR')
|
||||
|
||||
for ptr_record in response:
|
||||
hostname = str(ptr_record).rstrip('.')
|
||||
|
||||
if self._is_valid_domain(hostname):
|
||||
raw_data = {
|
||||
'query_type': 'PTR',
|
||||
'ip_address': ip,
|
||||
'hostname': hostname,
|
||||
'ttl': response.ttl
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
ip,
|
||||
hostname,
|
||||
RelationshipType.A_RECORD, # Reverse relationship
|
||||
RelationshipType.A_RECORD.default_confidence,
|
||||
raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=ip,
|
||||
target_node=hostname,
|
||||
relationship_type=RelationshipType.A_RECORD,
|
||||
confidence_score=RelationshipType.A_RECORD.default_confidence,
|
||||
raw_data=raw_data,
|
||||
discovery_method="reverse_dns_lookup"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.logger.debug(f"Reverse DNS lookup failed for {ip}: {e}")
|
||||
|
||||
return relationships
|
||||
|
||||
def _query_a_records(self, domain: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""Query A records for the domain."""
|
||||
relationships = []
|
||||
|
||||
#if not DNS_AVAILABLE:
|
||||
# return relationships
|
||||
|
||||
try:
|
||||
response = self.resolver.resolve(domain, 'A')
|
||||
|
||||
for a_record in response:
|
||||
ip_address = str(a_record)
|
||||
|
||||
raw_data = {
|
||||
'query_type': 'A',
|
||||
'domain': domain,
|
||||
'ip_address': ip_address,
|
||||
'ttl': response.ttl
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
domain,
|
||||
ip_address,
|
||||
RelationshipType.A_RECORD,
|
||||
RelationshipType.A_RECORD.default_confidence,
|
||||
raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=domain,
|
||||
target_node=ip_address,
|
||||
relationship_type=RelationshipType.A_RECORD,
|
||||
confidence_score=RelationshipType.A_RECORD.default_confidence,
|
||||
raw_data=raw_data,
|
||||
discovery_method="dns_a_record"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.logger.debug(f"A record query failed for {domain}: {e}")
|
||||
|
||||
return relationships
|
||||
|
||||
def _query_aaaa_records(self, domain: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""Query AAAA records (IPv6) for the domain."""
|
||||
relationships = []
|
||||
|
||||
#if not DNS_AVAILABLE:
|
||||
# return relationships
|
||||
|
||||
try:
|
||||
response = self.resolver.resolve(domain, 'AAAA')
|
||||
|
||||
for aaaa_record in response:
|
||||
ip_address = str(aaaa_record)
|
||||
|
||||
raw_data = {
|
||||
'query_type': 'AAAA',
|
||||
'domain': domain,
|
||||
'ip_address': ip_address,
|
||||
'ttl': response.ttl
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
domain,
|
||||
ip_address,
|
||||
RelationshipType.A_RECORD, # Using same type for IPv6
|
||||
RelationshipType.A_RECORD.default_confidence,
|
||||
raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=domain,
|
||||
target_node=ip_address,
|
||||
relationship_type=RelationshipType.A_RECORD,
|
||||
confidence_score=RelationshipType.A_RECORD.default_confidence,
|
||||
raw_data=raw_data,
|
||||
discovery_method="dns_aaaa_record"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.logger.debug(f"AAAA record query failed for {domain}: {e}")
|
||||
|
||||
return relationships
|
||||
|
||||
def _query_cname_records(self, domain: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""Query CNAME records for the domain."""
|
||||
relationships = []
|
||||
|
||||
#if not DNS_AVAILABLE:
|
||||
# return relationships
|
||||
|
||||
try:
|
||||
response = self.resolver.resolve(domain, 'CNAME')
|
||||
|
||||
for cname_record in response:
|
||||
target_domain = str(cname_record).rstrip('.')
|
||||
|
||||
if self._is_valid_domain(target_domain):
|
||||
raw_data = {
|
||||
'query_type': 'CNAME',
|
||||
'source_domain': domain,
|
||||
'target_domain': target_domain,
|
||||
'ttl': response.ttl
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
domain,
|
||||
target_domain,
|
||||
RelationshipType.CNAME_RECORD,
|
||||
RelationshipType.CNAME_RECORD.default_confidence,
|
||||
raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=domain,
|
||||
target_node=target_domain,
|
||||
relationship_type=RelationshipType.CNAME_RECORD,
|
||||
confidence_score=RelationshipType.CNAME_RECORD.default_confidence,
|
||||
raw_data=raw_data,
|
||||
discovery_method="dns_cname_record"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.logger.debug(f"CNAME record query failed for {domain}: {e}")
|
||||
|
||||
return relationships
|
||||
|
||||
def _query_mx_records(self, domain: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""Query MX records for the domain."""
|
||||
relationships = []
|
||||
|
||||
#if not DNS_AVAILABLE:
|
||||
# return relationships
|
||||
|
||||
try:
|
||||
response = self.resolver.resolve(domain, 'MX')
|
||||
|
||||
for mx_record in response:
|
||||
mx_host = str(mx_record.exchange).rstrip('.')
|
||||
|
||||
if self._is_valid_domain(mx_host):
|
||||
raw_data = {
|
||||
'query_type': 'MX',
|
||||
'domain': domain,
|
||||
'mx_host': mx_host,
|
||||
'priority': mx_record.preference,
|
||||
'ttl': response.ttl
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
domain,
|
||||
mx_host,
|
||||
RelationshipType.MX_RECORD,
|
||||
RelationshipType.MX_RECORD.default_confidence,
|
||||
raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=domain,
|
||||
target_node=mx_host,
|
||||
relationship_type=RelationshipType.MX_RECORD,
|
||||
confidence_score=RelationshipType.MX_RECORD.default_confidence,
|
||||
raw_data=raw_data,
|
||||
discovery_method="dns_mx_record"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.logger.debug(f"MX record query failed for {domain}: {e}")
|
||||
|
||||
return relationships
|
||||
|
||||
def _query_ns_records(self, domain: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""Query NS records for the domain."""
|
||||
relationships = []
|
||||
|
||||
#if not DNS_AVAILABLE:
|
||||
# return relationships
|
||||
|
||||
try:
|
||||
response = self.resolver.resolve(domain, 'NS')
|
||||
|
||||
for ns_record in response:
|
||||
ns_host = str(ns_record).rstrip('.')
|
||||
|
||||
if self._is_valid_domain(ns_host):
|
||||
raw_data = {
|
||||
'query_type': 'NS',
|
||||
'domain': domain,
|
||||
'ns_host': ns_host,
|
||||
'ttl': response.ttl
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
domain,
|
||||
ns_host,
|
||||
RelationshipType.NS_RECORD,
|
||||
RelationshipType.NS_RECORD.default_confidence,
|
||||
raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=domain,
|
||||
target_node=ns_host,
|
||||
relationship_type=RelationshipType.NS_RECORD,
|
||||
confidence_score=RelationshipType.NS_RECORD.default_confidence,
|
||||
raw_data=raw_data,
|
||||
discovery_method="dns_ns_record"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.logger.debug(f"NS record query failed for {domain}: {e}")
|
||||
|
||||
return relationships
|
299
providers/shodan_provider.py
Normal file
299
providers/shodan_provider.py
Normal file
@ -0,0 +1,299 @@
|
||||
"""
|
||||
Shodan provider for DNSRecon.
|
||||
Discovers IP relationships and infrastructure context through Shodan API.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from urllib.parse import quote
|
||||
from .base_provider import BaseProvider
|
||||
from core.graph_manager import RelationshipType
|
||||
from config import config
|
||||
|
||||
|
||||
class ShodanProvider(BaseProvider):
|
||||
"""
|
||||
Provider for querying Shodan API for IP address and hostname information.
|
||||
Requires valid API key and respects Shodan's rate limits.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Shodan provider with appropriate rate limiting."""
|
||||
super().__init__(
|
||||
name="shodan",
|
||||
rate_limit=60, # Shodan API has various rate limits depending on plan
|
||||
timeout=30
|
||||
)
|
||||
self.base_url = "https://api.shodan.io"
|
||||
self.api_key = config.get_api_key('shodan')
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Return the provider name."""
|
||||
return "shodan"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""
|
||||
Check if Shodan provider is available (has valid API key).
|
||||
"""
|
||||
return self.api_key is not None and len(self.api_key.strip()) > 0
|
||||
|
||||
def query_domain(self, domain: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""
|
||||
Query Shodan for information about a domain.
|
||||
Uses Shodan's hostname search to find associated IPs.
|
||||
|
||||
Args:
|
||||
domain: Domain to investigate
|
||||
|
||||
Returns:
|
||||
List of relationships discovered from Shodan data
|
||||
"""
|
||||
if not self._is_valid_domain(domain) or not self.is_available():
|
||||
return []
|
||||
|
||||
relationships = []
|
||||
|
||||
try:
|
||||
# Search for hostname in Shodan
|
||||
search_query = f"hostname:{domain}"
|
||||
url = f"{self.base_url}/shodan/host/search"
|
||||
params = {
|
||||
'key': self.api_key,
|
||||
'query': search_query,
|
||||
'minify': True # Get minimal data to reduce bandwidth
|
||||
}
|
||||
|
||||
response = self.make_request(url, method="GET", params=params, target_indicator=domain)
|
||||
|
||||
if not response or response.status_code != 200:
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
|
||||
if 'matches' not in data:
|
||||
return []
|
||||
|
||||
# Process search results
|
||||
for match in data['matches']:
|
||||
ip_address = match.get('ip_str')
|
||||
hostnames = match.get('hostnames', [])
|
||||
|
||||
if ip_address and domain in hostnames:
|
||||
raw_data = {
|
||||
'ip_address': ip_address,
|
||||
'hostnames': hostnames,
|
||||
'country': match.get('location', {}).get('country_name', ''),
|
||||
'city': match.get('location', {}).get('city', ''),
|
||||
'isp': match.get('isp', ''),
|
||||
'org': match.get('org', ''),
|
||||
'ports': match.get('ports', []),
|
||||
'last_update': match.get('last_update', '')
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
domain,
|
||||
ip_address,
|
||||
RelationshipType.A_RECORD, # Domain resolves to IP
|
||||
RelationshipType.A_RECORD.default_confidence,
|
||||
raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=domain,
|
||||
target_node=ip_address,
|
||||
relationship_type=RelationshipType.A_RECORD,
|
||||
confidence_score=RelationshipType.A_RECORD.default_confidence,
|
||||
raw_data=raw_data,
|
||||
discovery_method="shodan_hostname_search"
|
||||
)
|
||||
|
||||
# Also create relationships to other hostnames on the same IP
|
||||
for hostname in hostnames:
|
||||
if hostname != domain and self._is_valid_domain(hostname):
|
||||
hostname_raw_data = {
|
||||
'shared_ip': ip_address,
|
||||
'all_hostnames': hostnames,
|
||||
'discovery_context': 'shared_hosting'
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
domain,
|
||||
hostname,
|
||||
RelationshipType.PASSIVE_DNS, # Shared hosting relationship
|
||||
0.6, # Lower confidence for shared hosting
|
||||
hostname_raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=domain,
|
||||
target_node=hostname,
|
||||
relationship_type=RelationshipType.PASSIVE_DNS,
|
||||
confidence_score=0.6,
|
||||
raw_data=hostname_raw_data,
|
||||
discovery_method="shodan_shared_hosting"
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.logger.error(f"Failed to parse JSON response from Shodan: {e}")
|
||||
except Exception as e:
|
||||
self.logger.logger.error(f"Error querying Shodan for domain {domain}: {e}")
|
||||
|
||||
return relationships
|
||||
|
||||
def query_ip(self, ip: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""
|
||||
Query Shodan for information about an IP address.
|
||||
|
||||
Args:
|
||||
ip: IP address to investigate
|
||||
|
||||
Returns:
|
||||
List of relationships discovered from Shodan IP data
|
||||
"""
|
||||
if not self._is_valid_ip(ip) or not self.is_available():
|
||||
return []
|
||||
|
||||
relationships = []
|
||||
|
||||
try:
|
||||
# Query Shodan host information
|
||||
url = f"{self.base_url}/shodan/host/{ip}"
|
||||
params = {'key': self.api_key}
|
||||
|
||||
response = self.make_request(url, method="GET", params=params, target_indicator=ip)
|
||||
|
||||
if not response or response.status_code != 200:
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Extract hostname relationships
|
||||
hostnames = data.get('hostnames', [])
|
||||
for hostname in hostnames:
|
||||
if self._is_valid_domain(hostname):
|
||||
raw_data = {
|
||||
'ip_address': ip,
|
||||
'hostname': hostname,
|
||||
'country': data.get('country_name', ''),
|
||||
'city': data.get('city', ''),
|
||||
'isp': data.get('isp', ''),
|
||||
'org': data.get('org', ''),
|
||||
'asn': data.get('asn', ''),
|
||||
'ports': data.get('ports', []),
|
||||
'last_update': data.get('last_update', ''),
|
||||
'os': data.get('os', '')
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
ip,
|
||||
hostname,
|
||||
RelationshipType.A_RECORD, # IP resolves to hostname
|
||||
RelationshipType.A_RECORD.default_confidence,
|
||||
raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=ip,
|
||||
target_node=hostname,
|
||||
relationship_type=RelationshipType.A_RECORD,
|
||||
confidence_score=RelationshipType.A_RECORD.default_confidence,
|
||||
raw_data=raw_data,
|
||||
discovery_method="shodan_host_lookup"
|
||||
)
|
||||
|
||||
# Extract ASN relationship if available
|
||||
asn = data.get('asn')
|
||||
if asn:
|
||||
asn_name = f"AS{asn}"
|
||||
|
||||
asn_raw_data = {
|
||||
'ip_address': ip,
|
||||
'asn': asn,
|
||||
'isp': data.get('isp', ''),
|
||||
'org': data.get('org', '')
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
ip,
|
||||
asn_name,
|
||||
RelationshipType.ASN_MEMBERSHIP,
|
||||
RelationshipType.ASN_MEMBERSHIP.default_confidence,
|
||||
asn_raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=ip,
|
||||
target_node=asn_name,
|
||||
relationship_type=RelationshipType.ASN_MEMBERSHIP,
|
||||
confidence_score=RelationshipType.ASN_MEMBERSHIP.default_confidence,
|
||||
raw_data=asn_raw_data,
|
||||
discovery_method="shodan_asn_lookup"
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.logger.error(f"Failed to parse JSON response from Shodan: {e}")
|
||||
except Exception as e:
|
||||
self.logger.logger.error(f"Error querying Shodan for IP {ip}: {e}")
|
||||
|
||||
return relationships
|
||||
|
||||
def search_by_organization(self, org_name: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search Shodan for hosts belonging to a specific organization.
|
||||
|
||||
Args:
|
||||
org_name: Organization name to search for
|
||||
|
||||
Returns:
|
||||
List of host information dictionaries
|
||||
"""
|
||||
if not self.is_available():
|
||||
return []
|
||||
|
||||
try:
|
||||
search_query = f"org:\"{org_name}\""
|
||||
url = f"{self.base_url}/shodan/host/search"
|
||||
params = {
|
||||
'key': self.api_key,
|
||||
'query': search_query,
|
||||
'minify': True
|
||||
}
|
||||
|
||||
response = self.make_request(url, method="GET", params=params, target_indicator=org_name)
|
||||
|
||||
if response and response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get('matches', [])
|
||||
|
||||
except Exception as e:
|
||||
self.logger.logger.error(f"Error searching Shodan by organization {org_name}: {e}")
|
||||
|
||||
return []
|
||||
|
||||
def get_host_services(self, ip: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get service information for a specific IP address.
|
||||
|
||||
Args:
|
||||
ip: IP address to query
|
||||
|
||||
Returns:
|
||||
List of service information dictionaries
|
||||
"""
|
||||
if not self._is_valid_ip(ip) or not self.is_available():
|
||||
return []
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/shodan/host/{ip}"
|
||||
params = {'key': self.api_key}
|
||||
|
||||
response = self.make_request(url, method="GET", params=params, target_indicator=ip)
|
||||
|
||||
if response and response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get('data', []) # Service banners
|
||||
|
||||
except Exception as e:
|
||||
self.logger.logger.error(f"Error getting Shodan services for IP {ip}: {e}")
|
||||
|
||||
return []
|
334
providers/virustotal_provider.py
Normal file
334
providers/virustotal_provider.py
Normal file
@ -0,0 +1,334 @@
|
||||
"""
|
||||
VirusTotal provider for DNSRecon.
|
||||
Discovers domain relationships through passive DNS and URL analysis.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from .base_provider import BaseProvider
|
||||
from core.graph_manager import RelationshipType
|
||||
from config import config
|
||||
|
||||
|
||||
class VirusTotalProvider(BaseProvider):
|
||||
"""
|
||||
Provider for querying VirusTotal API for passive DNS and domain reputation data.
|
||||
Requires valid API key and strictly respects free tier rate limits.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize VirusTotal provider with strict rate limiting for free tier."""
|
||||
super().__init__(
|
||||
name="virustotal",
|
||||
rate_limit=4, # Free tier: 4 requests per minute
|
||||
timeout=30
|
||||
)
|
||||
self.base_url = "https://www.virustotal.com/vtapi/v2"
|
||||
self.api_key = config.get_api_key('virustotal')
|
||||
|
||||
def get_name(self) -> str:
|
||||
"""Return the provider name."""
|
||||
return "virustotal"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""
|
||||
Check if VirusTotal provider is available (has valid API key).
|
||||
"""
|
||||
return self.api_key is not None and len(self.api_key.strip()) > 0
|
||||
|
||||
def query_domain(self, domain: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""
|
||||
Query VirusTotal for domain information including passive DNS.
|
||||
|
||||
Args:
|
||||
domain: Domain to investigate
|
||||
|
||||
Returns:
|
||||
List of relationships discovered from VirusTotal data
|
||||
"""
|
||||
if not self._is_valid_domain(domain) or not self.is_available():
|
||||
return []
|
||||
|
||||
relationships = []
|
||||
|
||||
# Query domain report
|
||||
domain_relationships = self._query_domain_report(domain)
|
||||
relationships.extend(domain_relationships)
|
||||
|
||||
# Query passive DNS for the domain
|
||||
passive_dns_relationships = self._query_passive_dns_domain(domain)
|
||||
relationships.extend(passive_dns_relationships)
|
||||
|
||||
return relationships
|
||||
|
||||
def query_ip(self, ip: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""
|
||||
Query VirusTotal for IP address information including passive DNS.
|
||||
|
||||
Args:
|
||||
ip: IP address to investigate
|
||||
|
||||
Returns:
|
||||
List of relationships discovered from VirusTotal IP data
|
||||
"""
|
||||
if not self._is_valid_ip(ip) or not self.is_available():
|
||||
return []
|
||||
|
||||
relationships = []
|
||||
|
||||
# Query IP report
|
||||
ip_relationships = self._query_ip_report(ip)
|
||||
relationships.extend(ip_relationships)
|
||||
|
||||
# Query passive DNS for the IP
|
||||
passive_dns_relationships = self._query_passive_dns_ip(ip)
|
||||
relationships.extend(passive_dns_relationships)
|
||||
|
||||
return relationships
|
||||
|
||||
def _query_domain_report(self, domain: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""Query VirusTotal domain report."""
|
||||
relationships = []
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/domain/report"
|
||||
params = {
|
||||
'apikey': self.api_key,
|
||||
'domain': domain,
|
||||
'allinfo': 1 # Get comprehensive information
|
||||
}
|
||||
|
||||
response = self.make_request(url, method="GET", params=params, target_indicator=domain)
|
||||
|
||||
if not response or response.status_code != 200:
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
|
||||
if data.get('response_code') != 1:
|
||||
return []
|
||||
|
||||
# Extract resolved IPs
|
||||
resolutions = data.get('resolutions', [])
|
||||
for resolution in resolutions:
|
||||
ip_address = resolution.get('ip_address')
|
||||
last_resolved = resolution.get('last_resolved')
|
||||
|
||||
if ip_address and self._is_valid_ip(ip_address):
|
||||
raw_data = {
|
||||
'domain': domain,
|
||||
'ip_address': ip_address,
|
||||
'last_resolved': last_resolved,
|
||||
'source': 'virustotal_domain_report'
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
domain,
|
||||
ip_address,
|
||||
RelationshipType.PASSIVE_DNS,
|
||||
RelationshipType.PASSIVE_DNS.default_confidence,
|
||||
raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=domain,
|
||||
target_node=ip_address,
|
||||
relationship_type=RelationshipType.PASSIVE_DNS,
|
||||
confidence_score=RelationshipType.PASSIVE_DNS.default_confidence,
|
||||
raw_data=raw_data,
|
||||
discovery_method="virustotal_domain_resolution"
|
||||
)
|
||||
|
||||
# Extract subdomains
|
||||
subdomains = data.get('subdomains', [])
|
||||
for subdomain in subdomains:
|
||||
if subdomain != domain and self._is_valid_domain(subdomain):
|
||||
raw_data = {
|
||||
'parent_domain': domain,
|
||||
'subdomain': subdomain,
|
||||
'source': 'virustotal_subdomain_discovery'
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
domain,
|
||||
subdomain,
|
||||
RelationshipType.PASSIVE_DNS,
|
||||
0.7, # Medium-high confidence for subdomains
|
||||
raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=domain,
|
||||
target_node=subdomain,
|
||||
relationship_type=RelationshipType.PASSIVE_DNS,
|
||||
confidence_score=0.7,
|
||||
raw_data=raw_data,
|
||||
discovery_method="virustotal_subdomain_discovery"
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.logger.error(f"Failed to parse JSON response from VirusTotal: {e}")
|
||||
except Exception as e:
|
||||
self.logger.logger.error(f"Error querying VirusTotal domain report for {domain}: {e}")
|
||||
|
||||
return relationships
|
||||
|
||||
def _query_ip_report(self, ip: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""Query VirusTotal IP report."""
|
||||
relationships = []
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/ip-address/report"
|
||||
params = {
|
||||
'apikey': self.api_key,
|
||||
'ip': ip
|
||||
}
|
||||
|
||||
response = self.make_request(url, method="GET", params=params, target_indicator=ip)
|
||||
|
||||
if not response or response.status_code != 200:
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
|
||||
if data.get('response_code') != 1:
|
||||
return []
|
||||
|
||||
# Extract resolved domains
|
||||
resolutions = data.get('resolutions', [])
|
||||
for resolution in resolutions:
|
||||
hostname = resolution.get('hostname')
|
||||
last_resolved = resolution.get('last_resolved')
|
||||
|
||||
if hostname and self._is_valid_domain(hostname):
|
||||
raw_data = {
|
||||
'ip_address': ip,
|
||||
'hostname': hostname,
|
||||
'last_resolved': last_resolved,
|
||||
'source': 'virustotal_ip_report'
|
||||
}
|
||||
|
||||
relationships.append((
|
||||
ip,
|
||||
hostname,
|
||||
RelationshipType.PASSIVE_DNS,
|
||||
RelationshipType.PASSIVE_DNS.default_confidence,
|
||||
raw_data
|
||||
))
|
||||
|
||||
self.log_relationship_discovery(
|
||||
source_node=ip,
|
||||
target_node=hostname,
|
||||
relationship_type=RelationshipType.PASSIVE_DNS,
|
||||
confidence_score=RelationshipType.PASSIVE_DNS.default_confidence,
|
||||
raw_data=raw_data,
|
||||
discovery_method="virustotal_ip_resolution"
|
||||
)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.logger.error(f"Failed to parse JSON response from VirusTotal: {e}")
|
||||
except Exception as e:
|
||||
self.logger.logger.error(f"Error querying VirusTotal IP report for {ip}: {e}")
|
||||
|
||||
return relationships
|
||||
|
||||
def _query_passive_dns_domain(self, domain: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""Query VirusTotal passive DNS for domain."""
|
||||
# Note: VirusTotal's passive DNS API might require a premium subscription
|
||||
# This is a placeholder for the endpoint structure
|
||||
return []
|
||||
|
||||
def _query_passive_dns_ip(self, ip: str) -> List[Tuple[str, str, RelationshipType, float, Dict[str, Any]]]:
|
||||
"""Query VirusTotal passive DNS for IP."""
|
||||
# Note: VirusTotal's passive DNS API might require a premium subscription
|
||||
# This is a placeholder for the endpoint structure
|
||||
return []
|
||||
|
||||
def get_domain_reputation(self, domain: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get domain reputation information from VirusTotal.
|
||||
|
||||
Args:
|
||||
domain: Domain to check reputation for
|
||||
|
||||
Returns:
|
||||
Dictionary containing reputation data
|
||||
"""
|
||||
if not self._is_valid_domain(domain) or not self.is_available():
|
||||
return {}
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/domain/report"
|
||||
params = {
|
||||
'apikey': self.api_key,
|
||||
'domain': domain
|
||||
}
|
||||
|
||||
response = self.make_request(url, method="GET", params=params, target_indicator=domain)
|
||||
|
||||
if response and response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
if data.get('response_code') == 1:
|
||||
return {
|
||||
'positives': data.get('positives', 0),
|
||||
'total': data.get('total', 0),
|
||||
'scan_date': data.get('scan_date', ''),
|
||||
'permalink': data.get('permalink', ''),
|
||||
'reputation_score': self._calculate_reputation_score(data)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.logger.error(f"Error getting VirusTotal reputation for domain {domain}: {e}")
|
||||
|
||||
return {}
|
||||
|
||||
def get_ip_reputation(self, ip: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get IP reputation information from VirusTotal.
|
||||
|
||||
Args:
|
||||
ip: IP address to check reputation for
|
||||
|
||||
Returns:
|
||||
Dictionary containing reputation data
|
||||
"""
|
||||
if not self._is_valid_ip(ip) or not self.is_available():
|
||||
return {}
|
||||
|
||||
try:
|
||||
url = f"{self.base_url}/ip-address/report"
|
||||
params = {
|
||||
'apikey': self.api_key,
|
||||
'ip': ip
|
||||
}
|
||||
|
||||
response = self.make_request(url, method="GET", params=params, target_indicator=ip)
|
||||
|
||||
if response and response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
if data.get('response_code') == 1:
|
||||
return {
|
||||
'positives': data.get('positives', 0),
|
||||
'total': data.get('total', 0),
|
||||
'scan_date': data.get('scan_date', ''),
|
||||
'permalink': data.get('permalink', ''),
|
||||
'reputation_score': self._calculate_reputation_score(data)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.logger.logger.error(f"Error getting VirusTotal reputation for IP {ip}: {e}")
|
||||
|
||||
return {}
|
||||
|
||||
def _calculate_reputation_score(self, data: Dict[str, Any]) -> float:
|
||||
"""Calculate a normalized reputation score (0.0 to 1.0)."""
|
||||
positives = data.get('positives', 0)
|
||||
total = data.get('total', 1) # Avoid division by zero
|
||||
|
||||
if total == 0:
|
||||
return 1.0 # No data means neutral
|
||||
|
||||
# Score is inverse of detection ratio (lower detection = higher reputation)
|
||||
return max(0.0, 1.0 - (positives / total))
|
@ -4,3 +4,4 @@ requests>=2.31.0
|
||||
python-dateutil>=2.8.2
|
||||
Werkzeug>=2.3.7
|
||||
urllib3>=2.0.0
|
||||
dnspython>=2.4.2
|
@ -64,6 +64,18 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-indicator.scanning {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.completed {
|
||||
background-color: #00ff41;
|
||||
}
|
||||
|
||||
.status-indicator.error {
|
||||
background-color: #ff6b6b;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@ -266,6 +278,7 @@ input[type="text"]:focus, select:focus {
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
@ -274,6 +287,23 @@ input[type="text"]:focus, select:focus {
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 5px rgba(0, 255, 65, 0.5);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.progress-fill::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
animation: shimmer 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
/* Visualization Panel */
|
||||
@ -292,6 +322,37 @@ input[type="text"]:focus, select:focus {
|
||||
position: relative;
|
||||
background-color: #1a1a1a;
|
||||
border-top: 1px solid #444;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.graph-container.expanded {
|
||||
height: 700px;
|
||||
}
|
||||
|
||||
.graph-controls {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.graph-control-btn {
|
||||
background: rgba(42, 42, 42, 0.9);
|
||||
border: 1px solid #555;
|
||||
color: #c7c7c7;
|
||||
padding: 0.5rem;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.graph-control-btn:hover {
|
||||
border-color: #00ff41;
|
||||
color: #00ff41;
|
||||
background: rgba(42, 42, 42, 1);
|
||||
}
|
||||
|
||||
.graph-placeholder {
|
||||
@ -333,6 +394,20 @@ input[type="text"]:focus, select:focus {
|
||||
border-top: 1px solid #444;
|
||||
}
|
||||
|
||||
.legend-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-title {
|
||||
font-size: 0.7rem;
|
||||
color: #00ff41;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -344,6 +419,7 @@ input[type="text"]:focus, select:focus {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #444;
|
||||
}
|
||||
|
||||
.legend-edge {
|
||||
@ -353,10 +429,16 @@ input[type="text"]:focus, select:focus {
|
||||
|
||||
.legend-edge.high-confidence {
|
||||
background-color: #00ff41;
|
||||
box-shadow: 0 0 3px rgba(0, 255, 65, 0.5);
|
||||
}
|
||||
|
||||
.legend-edge.medium-confidence {
|
||||
background-color: #ff9900;
|
||||
box-shadow: 0 0 3px rgba(255, 153, 0, 0.5);
|
||||
}
|
||||
|
||||
.legend-edge.low-confidence {
|
||||
background-color: #666666;
|
||||
}
|
||||
|
||||
/* Provider Panel */
|
||||
@ -375,9 +457,11 @@ input[type="text"]:focus, select:focus {
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #444;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.provider-item:hover {
|
||||
border-color: #555;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
@ -389,6 +473,7 @@ input[type="text"]:focus, select:focus {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.provider-status.enabled {
|
||||
@ -401,12 +486,78 @@ input[type="text"]:focus, select:focus {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.provider-status.api-key-required {
|
||||
background-color: #5c4c2c;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.provider-stats {
|
||||
font-size: 0.8rem;
|
||||
color: #999;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.provider-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.provider-stat-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.provider-stat-value {
|
||||
color: #00ff41;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.node-info-popup {
|
||||
position: fixed;
|
||||
background: rgba(42, 42, 42, 0.95);
|
||||
border: 1px solid #555;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
color: #c7c7c7;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 0.8rem;
|
||||
max-width: 300px;
|
||||
z-index: 1001;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.node-info-title {
|
||||
color: #00ff41;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #444;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.node-info-detail {
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.node-info-label {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.node-info-value {
|
||||
color: #c7c7c7;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: #0a0a0a;
|
||||
@ -437,6 +588,7 @@ input[type="text"]:focus, select:focus {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
@ -447,6 +599,18 @@ input[type="text"]:focus, select:focus {
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
animation: slideInDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@ -480,6 +644,12 @@ input[type="text"]:focus, select:focus {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
color: #999;
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@ -495,6 +665,7 @@ input[type="text"]:focus, select:focus {
|
||||
|
||||
.detail-value {
|
||||
color: #c7c7c7;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@ -552,6 +723,40 @@ input[type="text"]:focus, select:focus {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(26, 26, 26, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #444;
|
||||
border-top: 3px solid #00ff41;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 1rem;
|
||||
color: #999;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff6b6b !important;
|
||||
border-color: #ff6b6b !important;
|
||||
@ -599,3 +804,100 @@ input[type="text"]:focus, select:focus {
|
||||
.amber {
|
||||
color: #ff9900;
|
||||
}
|
||||
|
||||
|
||||
.apikey-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.apikey-section label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #c7c7c7;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.apikey-section input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background-color: #1a1a1a;
|
||||
border: 1px solid #555;
|
||||
color: #c7c7c7;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.apikey-section input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: #00ff41;
|
||||
box-shadow: 0 0 5px rgba(0, 255, 65, 0.5);
|
||||
}
|
||||
|
||||
.apikey-help {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
margin-top: 0.25rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
.message-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1002;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.message-toast {
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
.message-toast.success {
|
||||
background: #2c5c34;
|
||||
border-left: 4px solid #00ff41;
|
||||
}
|
||||
|
||||
.message-toast.error {
|
||||
background: #5c2c2c;
|
||||
border-left: 4px solid #ff6b6b;
|
||||
}
|
||||
|
||||
.message-toast.warning {
|
||||
background: #5c4c2c;
|
||||
border-left: 4px solid #ff9900;
|
||||
}
|
||||
|
||||
.message-toast.info {
|
||||
background: #2c3e5c;
|
||||
border-left: 4px solid #00aaff;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Graph visualization module for DNSRecon
|
||||
* Handles network graph rendering using vis.js
|
||||
* Handles network graph rendering using vis.js with enhanced Phase 2 features
|
||||
*/
|
||||
|
||||
class GraphManager {
|
||||
@ -10,40 +10,57 @@ class GraphManager {
|
||||
this.nodes = new vis.DataSet();
|
||||
this.edges = new vis.DataSet();
|
||||
this.isInitialized = false;
|
||||
this.currentLayout = 'physics';
|
||||
this.nodeInfoPopup = null;
|
||||
|
||||
// Graph options for cybersecurity theme
|
||||
// Enhanced graph options for Phase 2
|
||||
this.options = {
|
||||
nodes: {
|
||||
shape: 'dot',
|
||||
size: 12,
|
||||
size: 15,
|
||||
font: {
|
||||
size: 11,
|
||||
size: 12,
|
||||
color: '#c7c7c7',
|
||||
face: 'Roboto Mono, monospace',
|
||||
background: 'rgba(26, 26, 26, 0.8)',
|
||||
strokeWidth: 1,
|
||||
background: 'rgba(26, 26, 26, 0.9)',
|
||||
strokeWidth: 2,
|
||||
strokeColor: '#000000'
|
||||
},
|
||||
borderWidth: 2,
|
||||
borderColor: '#444',
|
||||
shadow: {
|
||||
enabled: true,
|
||||
color: 'rgba(0, 0, 0, 0.3)',
|
||||
size: 3,
|
||||
x: 1,
|
||||
y: 1
|
||||
color: 'rgba(0, 0, 0, 0.5)',
|
||||
size: 5,
|
||||
x: 2,
|
||||
y: 2
|
||||
},
|
||||
scaling: {
|
||||
min: 10,
|
||||
max: 30,
|
||||
label: {
|
||||
enabled: true,
|
||||
min: 8,
|
||||
max: 20
|
||||
max: 16
|
||||
}
|
||||
},
|
||||
chosen: {
|
||||
node: (values, id, selected, hovering) => {
|
||||
values.borderColor = '#00ff41';
|
||||
values.borderWidth = 3;
|
||||
values.shadow = true;
|
||||
values.shadowColor = 'rgba(0, 255, 65, 0.6)';
|
||||
values.shadowSize = 10;
|
||||
}
|
||||
}
|
||||
},
|
||||
edges: {
|
||||
width: 2,
|
||||
color: {
|
||||
color: '#444',
|
||||
color: '#555',
|
||||
highlight: '#00ff41',
|
||||
hover: '#ff9900'
|
||||
hover: '#ff9900',
|
||||
inherit: false
|
||||
},
|
||||
font: {
|
||||
size: 10,
|
||||
@ -56,62 +73,84 @@ class GraphManager {
|
||||
arrows: {
|
||||
to: {
|
||||
enabled: true,
|
||||
scaleFactor: 0.8,
|
||||
scaleFactor: 1,
|
||||
type: 'arrow'
|
||||
}
|
||||
},
|
||||
smooth: {
|
||||
enabled: true,
|
||||
type: 'dynamic',
|
||||
roundness: 0.5
|
||||
roundness: 0.6
|
||||
},
|
||||
shadow: {
|
||||
enabled: true,
|
||||
color: 'rgba(0, 0, 0, 0.2)',
|
||||
size: 2,
|
||||
color: 'rgba(0, 0, 0, 0.3)',
|
||||
size: 3,
|
||||
x: 1,
|
||||
y: 1
|
||||
},
|
||||
chosen: {
|
||||
edge: (values, id, selected, hovering) => {
|
||||
values.color = '#00ff41';
|
||||
values.width = 4;
|
||||
values.shadow = true;
|
||||
values.shadowColor = 'rgba(0, 255, 65, 0.4)';
|
||||
}
|
||||
}
|
||||
},
|
||||
physics: {
|
||||
enabled: true,
|
||||
stabilization: {
|
||||
enabled: true,
|
||||
iterations: 100,
|
||||
updateInterval: 25
|
||||
iterations: 150,
|
||||
updateInterval: 50
|
||||
},
|
||||
barnesHut: {
|
||||
gravitationalConstant: -2000,
|
||||
centralGravity: 0.3,
|
||||
springLength: 95,
|
||||
springConstant: 0.04,
|
||||
damping: 0.09,
|
||||
avoidOverlap: 0.1
|
||||
gravitationalConstant: -3000,
|
||||
centralGravity: 0.4,
|
||||
springLength: 120,
|
||||
springConstant: 0.05,
|
||||
damping: 0.1,
|
||||
avoidOverlap: 0.2
|
||||
},
|
||||
maxVelocity: 50,
|
||||
maxVelocity: 30,
|
||||
minVelocity: 0.1,
|
||||
solver: 'barnesHut',
|
||||
timestep: 0.35,
|
||||
timestep: 0.4,
|
||||
adaptiveTimestep: true
|
||||
},
|
||||
interaction: {
|
||||
hover: true,
|
||||
hoverConnectedEdges: true,
|
||||
selectConnectedEdges: true,
|
||||
tooltipDelay: 200,
|
||||
tooltipDelay: 300,
|
||||
hideEdgesOnDrag: false,
|
||||
hideNodesOnDrag: false
|
||||
hideNodesOnDrag: false,
|
||||
zoomView: true,
|
||||
dragView: true,
|
||||
multiselect: true
|
||||
},
|
||||
layout: {
|
||||
improvedLayout: true
|
||||
improvedLayout: true,
|
||||
randomSeed: 2
|
||||
}
|
||||
};
|
||||
|
||||
this.setupEventHandlers();
|
||||
this.createNodeInfoPopup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the network graph
|
||||
* Create floating node info popup
|
||||
*/
|
||||
createNodeInfoPopup() {
|
||||
this.nodeInfoPopup = document.createElement('div');
|
||||
this.nodeInfoPopup.className = 'node-info-popup';
|
||||
this.nodeInfoPopup.style.display = 'none';
|
||||
document.body.appendChild(this.nodeInfoPopup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the network graph with enhanced features
|
||||
*/
|
||||
initialize() {
|
||||
if (this.isInitialized) {
|
||||
@ -134,7 +173,10 @@ class GraphManager {
|
||||
placeholder.style.display = 'none';
|
||||
}
|
||||
|
||||
console.log('Graph initialized successfully');
|
||||
// Add graph controls
|
||||
this.addGraphControls();
|
||||
|
||||
console.log('Enhanced graph initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize graph:', error);
|
||||
this.showError('Failed to initialize visualization');
|
||||
@ -142,33 +184,89 @@ class GraphManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup network event handlers
|
||||
* Add interactive graph controls
|
||||
*/
|
||||
addGraphControls() {
|
||||
const controlsContainer = document.createElement('div');
|
||||
controlsContainer.className = 'graph-controls';
|
||||
controlsContainer.innerHTML = `
|
||||
<button class="graph-control-btn" id="graph-fit" title="Fit to Screen">[FIT]</button>
|
||||
<button class="graph-control-btn" id="graph-reset" title="Reset View">[RESET]</button>
|
||||
<button class="graph-control-btn" id="graph-physics" title="Toggle Physics">[PHYSICS]</button>
|
||||
<button class="graph-control-btn" id="graph-cluster" title="Cluster Nodes">[CLUSTER]</button>
|
||||
`;
|
||||
|
||||
this.container.appendChild(controlsContainer);
|
||||
|
||||
// Add control event listeners
|
||||
document.getElementById('graph-fit').addEventListener('click', () => this.fitView());
|
||||
document.getElementById('graph-reset').addEventListener('click', () => this.resetView());
|
||||
document.getElementById('graph-physics').addEventListener('click', () => this.togglePhysics());
|
||||
document.getElementById('graph-cluster').addEventListener('click', () => this.toggleClustering());
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup enhanced network event handlers
|
||||
*/
|
||||
setupNetworkEvents() {
|
||||
if (!this.network) return;
|
||||
|
||||
// Node click event
|
||||
// Node click event with enhanced details
|
||||
this.network.on('click', (params) => {
|
||||
if (params.nodes.length > 0) {
|
||||
const nodeId = params.nodes[0];
|
||||
this.showNodeDetails(nodeId);
|
||||
this.highlightNodeConnections(nodeId);
|
||||
} else {
|
||||
this.clearHighlights();
|
||||
}
|
||||
});
|
||||
|
||||
// Hover events for tooltips
|
||||
// Enhanced hover events
|
||||
this.network.on('hoverNode', (params) => {
|
||||
const nodeId = params.node;
|
||||
const node = this.nodes.get(nodeId);
|
||||
if (node) {
|
||||
this.showTooltip(params.pointer.DOM, node);
|
||||
this.showNodeInfoPopup(params.pointer.DOM, node);
|
||||
this.highlightConnectedNodes(nodeId, true);
|
||||
}
|
||||
});
|
||||
|
||||
this.network.on('blurNode', () => {
|
||||
this.hideTooltip();
|
||||
this.network.on('blurNode', (params) => {
|
||||
this.hideNodeInfoPopup();
|
||||
this.clearHoverHighlights();
|
||||
});
|
||||
|
||||
// Stabilization events
|
||||
// Edge hover events
|
||||
this.network.on('hoverEdge', (params) => {
|
||||
const edgeId = params.edge;
|
||||
const edge = this.edges.get(edgeId);
|
||||
if (edge) {
|
||||
this.showEdgeInfo(params.pointer.DOM, edge);
|
||||
}
|
||||
});
|
||||
|
||||
this.network.on('blurEdge', () => {
|
||||
this.hideNodeInfoPopup();
|
||||
});
|
||||
|
||||
// Double-click to focus on node
|
||||
this.network.on('doubleClick', (params) => {
|
||||
if (params.nodes.length > 0) {
|
||||
const nodeId = params.nodes[0];
|
||||
this.focusOnNode(nodeId);
|
||||
}
|
||||
});
|
||||
|
||||
// Context menu (right-click)
|
||||
this.network.on('oncontext', (params) => {
|
||||
params.event.preventDefault();
|
||||
if (params.nodes.length > 0) {
|
||||
this.showNodeContextMenu(params.pointer.DOM, params.nodes[0]);
|
||||
}
|
||||
});
|
||||
|
||||
// Stabilization events with progress
|
||||
this.network.on('stabilizationProgress', (params) => {
|
||||
const progress = params.iterations / params.total;
|
||||
this.updateStabilizationProgress(progress);
|
||||
@ -177,10 +275,16 @@ class GraphManager {
|
||||
this.network.on('stabilizationIterationsDone', () => {
|
||||
this.onStabilizationComplete();
|
||||
});
|
||||
|
||||
// Selection events
|
||||
this.network.on('select', (params) => {
|
||||
console.log('Selected nodes:', params.nodes);
|
||||
console.log('Selected edges:', params.edges);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update graph with new data
|
||||
* Update graph with new data and enhanced processing
|
||||
* @param {Object} graphData - Graph data from backend
|
||||
*/
|
||||
updateGraph(graphData) {
|
||||
@ -195,30 +299,41 @@ class GraphManager {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
// Process nodes
|
||||
// Process nodes with enhanced attributes
|
||||
const processedNodes = graphData.nodes.map(node => this.processNode(node));
|
||||
const processedEdges = graphData.edges.map(edge => this.processEdge(edge));
|
||||
|
||||
// Update datasets
|
||||
this.nodes.clear();
|
||||
this.edges.clear();
|
||||
this.nodes.add(processedNodes);
|
||||
this.edges.add(processedEdges);
|
||||
// Update datasets with animation
|
||||
const existingNodeIds = this.nodes.getIds();
|
||||
const existingEdgeIds = this.edges.getIds();
|
||||
|
||||
// Fit the view if this is the first update or graph is small
|
||||
if (processedNodes.length <= 10) {
|
||||
setTimeout(() => this.fitView(), 500);
|
||||
// Add new nodes with fade-in animation
|
||||
const newNodes = processedNodes.filter(node => !existingNodeIds.includes(node.id));
|
||||
const newEdges = processedEdges.filter(edge => !existingEdgeIds.includes(edge.id));
|
||||
|
||||
// Update existing data
|
||||
this.nodes.update(processedNodes);
|
||||
this.edges.update(processedEdges);
|
||||
|
||||
// Highlight new additions briefly
|
||||
if (newNodes.length > 0 || newEdges.length > 0) {
|
||||
setTimeout(() => this.highlightNewElements(newNodes, newEdges), 100);
|
||||
}
|
||||
|
||||
console.log(`Graph updated: ${processedNodes.length} nodes, ${processedEdges.length} edges`);
|
||||
// Auto-fit view for small graphs or first update
|
||||
if (processedNodes.length <= 10 || existingNodeIds.length === 0) {
|
||||
setTimeout(() => this.fitView(), 800);
|
||||
}
|
||||
|
||||
console.log(`Enhanced graph updated: ${processedNodes.length} nodes, ${processedEdges.length} edges (${newNodes.length} new nodes, ${newEdges.length} new edges)`);
|
||||
} catch (error) {
|
||||
console.error('Failed to update graph:', error);
|
||||
console.error('Failed to update enhanced graph:', error);
|
||||
this.showError('Failed to update visualization');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process node data for visualization
|
||||
* Process node data with enhanced styling and metadata
|
||||
* @param {Object} node - Raw node data
|
||||
* @returns {Object} Processed node data
|
||||
*/
|
||||
@ -230,25 +345,32 @@ class GraphManager {
|
||||
color: this.getNodeColor(node.type),
|
||||
size: this.getNodeSize(node.type),
|
||||
borderColor: this.getNodeBorderColor(node.type),
|
||||
metadata: node.metadata || {}
|
||||
shape: this.getNodeShape(node.type),
|
||||
metadata: node.metadata || {},
|
||||
type: node.type
|
||||
};
|
||||
|
||||
// Add type-specific styling
|
||||
if (node.type === 'domain') {
|
||||
processedNode.shape = 'dot';
|
||||
} else if (node.type === 'ip') {
|
||||
processedNode.shape = 'square';
|
||||
} else if (node.type === 'certificate') {
|
||||
processedNode.shape = 'diamond';
|
||||
} else if (node.type === 'asn') {
|
||||
processedNode.shape = 'triangle';
|
||||
// Add confidence-based styling
|
||||
if (node.confidence) {
|
||||
processedNode.borderWidth = Math.max(2, Math.floor(node.confidence * 5));
|
||||
}
|
||||
|
||||
// Add special styling for important nodes
|
||||
if (this.isImportantNode(node)) {
|
||||
processedNode.shadow = {
|
||||
enabled: true,
|
||||
color: 'rgba(0, 255, 65, 0.6)',
|
||||
size: 10,
|
||||
x: 2,
|
||||
y: 2
|
||||
};
|
||||
}
|
||||
|
||||
return processedNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process edge data for visualization
|
||||
* Process edge data with enhanced styling and metadata
|
||||
* @param {Object} edge - Raw edge data
|
||||
* @returns {Object} Processed edge data
|
||||
*/
|
||||
@ -262,9 +384,26 @@ class GraphManager {
|
||||
title: this.createEdgeTooltip(edge),
|
||||
width: this.getEdgeWidth(confidence),
|
||||
color: this.getEdgeColor(confidence),
|
||||
dashes: confidence < 0.6 ? [5, 5] : false
|
||||
dashes: confidence < 0.6 ? [5, 5] : false,
|
||||
metadata: {
|
||||
relationship_type: edge.label,
|
||||
confidence_score: confidence,
|
||||
source_provider: edge.source_provider,
|
||||
discovery_timestamp: edge.discovery_timestamp
|
||||
}
|
||||
};
|
||||
|
||||
// Add animation for high-confidence edges
|
||||
if (confidence >= 0.8) {
|
||||
processedEdge.shadow = {
|
||||
enabled: true,
|
||||
color: 'rgba(0, 255, 65, 0.3)',
|
||||
size: 5,
|
||||
x: 1,
|
||||
y: 1
|
||||
};
|
||||
}
|
||||
|
||||
return processedEdge;
|
||||
}
|
||||
|
||||
@ -340,6 +479,21 @@ class GraphManager {
|
||||
return sizes[nodeType] || 12;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enhanced node shape based on type
|
||||
* @param {string} nodeType - Node type
|
||||
* @returns {string} Shape name
|
||||
*/
|
||||
getNodeShape(nodeType) {
|
||||
const shapes = {
|
||||
'domain': 'dot',
|
||||
'ip': 'square',
|
||||
'certificate': 'diamond',
|
||||
'asn': 'triangle'
|
||||
};
|
||||
return shapes[nodeType] || 'dot';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get edge color based on confidence
|
||||
* @param {number} confidence - Confidence score
|
||||
@ -412,6 +566,19 @@ class GraphManager {
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if node is important based on connections or metadata
|
||||
* @param {Object} node - Node data
|
||||
* @returns {boolean} True if node is important
|
||||
*/
|
||||
isImportantNode(node) {
|
||||
// Mark nodes as important based on criteria
|
||||
if (node.type === 'domain' && node.id.includes('www.')) return false;
|
||||
if (node.metadata && node.metadata.connection_count > 3) return true;
|
||||
if (node.type === 'asn') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show node details in modal
|
||||
* @param {string} nodeId - Node identifier
|
||||
@ -428,20 +595,247 @@ class GraphManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Show tooltip
|
||||
* Show enhanced node info popup
|
||||
* @param {Object} position - Mouse position
|
||||
* @param {Object} node - Node data
|
||||
*/
|
||||
showTooltip(position, node) {
|
||||
// Tooltip is handled by vis.js automatically
|
||||
// This method is for custom tooltip implementation if needed
|
||||
showNodeInfoPopup(position, node) {
|
||||
if (!this.nodeInfoPopup) return;
|
||||
|
||||
const html = `
|
||||
<div class="node-info-title">${node.id}</div>
|
||||
<div class="node-info-detail">
|
||||
<span class="node-info-label">Type:</span>
|
||||
<span class="node-info-value">${node.type || 'Unknown'}</span>
|
||||
</div>
|
||||
${node.metadata && Object.keys(node.metadata).length > 0 ?
|
||||
'<div class="node-info-detail"><span class="node-info-label">Details:</span><span class="node-info-value">Click for more</span></div>' :
|
||||
''}
|
||||
`;
|
||||
|
||||
this.nodeInfoPopup.innerHTML = html;
|
||||
this.nodeInfoPopup.style.display = 'block';
|
||||
this.nodeInfoPopup.style.left = position.x + 15 + 'px';
|
||||
this.nodeInfoPopup.style.top = position.y - 10 + 'px';
|
||||
|
||||
// Ensure popup stays in viewport
|
||||
const rect = this.nodeInfoPopup.getBoundingClientRect();
|
||||
if (rect.right > window.innerWidth) {
|
||||
this.nodeInfoPopup.style.left = position.x - rect.width - 15 + 'px';
|
||||
}
|
||||
if (rect.bottom > window.innerHeight) {
|
||||
this.nodeInfoPopup.style.top = position.y - rect.height + 10 + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide tooltip
|
||||
* Show edge information tooltip
|
||||
* @param {Object} position - Mouse position
|
||||
* @param {Object} edge - Edge data
|
||||
*/
|
||||
hideTooltip() {
|
||||
// Tooltip hiding is handled by vis.js automatically
|
||||
showEdgeInfo(position, edge) {
|
||||
if (!this.nodeInfoPopup) return;
|
||||
|
||||
const confidence = edge.metadata ? edge.metadata.confidence_score : 0;
|
||||
const provider = edge.metadata ? edge.metadata.source_provider : 'Unknown';
|
||||
|
||||
const html = `
|
||||
<div class="node-info-title">${edge.metadata ? edge.metadata.relationship_type : 'Relationship'}</div>
|
||||
<div class="node-info-detail">
|
||||
<span class="node-info-label">Confidence:</span>
|
||||
<span class="node-info-value">${(confidence * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class="node-info-detail">
|
||||
<span class="node-info-label">Provider:</span>
|
||||
<span class="node-info-value">${provider}</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.nodeInfoPopup.innerHTML = html;
|
||||
this.nodeInfoPopup.style.display = 'block';
|
||||
this.nodeInfoPopup.style.left = position.x + 15 + 'px';
|
||||
this.nodeInfoPopup.style.top = position.y - 10 + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide node info popup
|
||||
*/
|
||||
hideNodeInfoPopup() {
|
||||
if (this.nodeInfoPopup) {
|
||||
this.nodeInfoPopup.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight node connections
|
||||
* @param {string} nodeId - Node to highlight
|
||||
*/
|
||||
highlightNodeConnections(nodeId) {
|
||||
const connectedNodes = this.network.getConnectedNodes(nodeId);
|
||||
const connectedEdges = this.network.getConnectedEdges(nodeId);
|
||||
|
||||
// Update node colors
|
||||
const nodeUpdates = connectedNodes.map(id => ({
|
||||
id: id,
|
||||
borderColor: '#ff9900',
|
||||
borderWidth: 3
|
||||
}));
|
||||
|
||||
nodeUpdates.push({
|
||||
id: nodeId,
|
||||
borderColor: '#00ff41',
|
||||
borderWidth: 4
|
||||
});
|
||||
|
||||
// Update edge colors
|
||||
const edgeUpdates = connectedEdges.map(id => ({
|
||||
id: id,
|
||||
color: { color: '#ff9900' },
|
||||
width: 3
|
||||
}));
|
||||
|
||||
this.nodes.update(nodeUpdates);
|
||||
this.edges.update(edgeUpdates);
|
||||
|
||||
// Store for cleanup
|
||||
this.highlightedElements = {
|
||||
nodes: connectedNodes.concat([nodeId]),
|
||||
edges: connectedEdges
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight connected nodes on hover
|
||||
* @param {string} nodeId - Node ID
|
||||
* @param {boolean} highlight - Whether to highlight or unhighlight
|
||||
*/
|
||||
highlightConnectedNodes(nodeId, highlight) {
|
||||
const connectedNodes = this.network.getConnectedNodes(nodeId);
|
||||
const connectedEdges = this.network.getConnectedEdges(nodeId);
|
||||
|
||||
if (highlight) {
|
||||
// Dim all other elements
|
||||
this.dimUnconnectedElements([nodeId, ...connectedNodes], connectedEdges);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dim elements not connected to the specified nodes
|
||||
* @param {Array} nodeIds - Node IDs to keep highlighted
|
||||
* @param {Array} edgeIds - Edge IDs to keep highlighted
|
||||
*/
|
||||
dimUnconnectedElements(nodeIds, edgeIds) {
|
||||
const allNodes = this.nodes.get();
|
||||
const allEdges = this.edges.get();
|
||||
|
||||
const nodeUpdates = allNodes.map(node => ({
|
||||
id: node.id,
|
||||
opacity: nodeIds.includes(node.id) ? 1 : 0.3
|
||||
}));
|
||||
|
||||
const edgeUpdates = allEdges.map(edge => ({
|
||||
id: edge.id,
|
||||
opacity: edgeIds.includes(edge.id) ? 1 : 0.1
|
||||
}));
|
||||
|
||||
this.nodes.update(nodeUpdates);
|
||||
this.edges.update(edgeUpdates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all highlights
|
||||
*/
|
||||
clearHighlights() {
|
||||
if (this.highlightedElements) {
|
||||
// Reset highlighted nodes
|
||||
const nodeUpdates = this.highlightedElements.nodes.map(id => {
|
||||
const originalNode = this.nodes.get(id);
|
||||
return {
|
||||
id: id,
|
||||
borderColor: this.getNodeBorderColor(originalNode.type),
|
||||
borderWidth: 2
|
||||
};
|
||||
});
|
||||
|
||||
// Reset highlighted edges
|
||||
const edgeUpdates = this.highlightedElements.edges.map(id => {
|
||||
const originalEdge = this.edges.get(id);
|
||||
return {
|
||||
id: id,
|
||||
color: this.getEdgeColor(originalEdge.metadata ? originalEdge.metadata.confidence_score : 0.5),
|
||||
width: this.getEdgeWidth(originalEdge.metadata ? originalEdge.metadata.confidence_score : 0.5)
|
||||
};
|
||||
});
|
||||
|
||||
this.nodes.update(nodeUpdates);
|
||||
this.edges.update(edgeUpdates);
|
||||
|
||||
this.highlightedElements = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear hover highlights
|
||||
*/
|
||||
clearHoverHighlights() {
|
||||
const allNodes = this.nodes.get();
|
||||
const allEdges = this.edges.get();
|
||||
|
||||
const nodeUpdates = allNodes.map(node => ({ id: node.id, opacity: 1 }));
|
||||
const edgeUpdates = allEdges.map(edge => ({ id: edge.id, opacity: 1 }));
|
||||
|
||||
this.nodes.update(nodeUpdates);
|
||||
this.edges.update(edgeUpdates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlight newly added elements
|
||||
* @param {Array} newNodes - New nodes
|
||||
* @param {Array} newEdges - New edges
|
||||
*/
|
||||
highlightNewElements(newNodes, newEdges) {
|
||||
// Briefly highlight new nodes
|
||||
const nodeHighlights = newNodes.map(node => ({
|
||||
id: node.id,
|
||||
borderColor: '#00ff41',
|
||||
borderWidth: 4,
|
||||
shadow: {
|
||||
enabled: true,
|
||||
color: 'rgba(0, 255, 65, 0.8)',
|
||||
size: 15,
|
||||
x: 2,
|
||||
y: 2
|
||||
}
|
||||
}));
|
||||
|
||||
// Briefly highlight new edges
|
||||
const edgeHighlights = newEdges.map(edge => ({
|
||||
id: edge.id,
|
||||
color: '#00ff41',
|
||||
width: 4
|
||||
}));
|
||||
|
||||
this.nodes.update(nodeHighlights);
|
||||
this.edges.update(edgeHighlights);
|
||||
|
||||
// Reset after animation
|
||||
setTimeout(() => {
|
||||
const nodeResets = newNodes.map(node => ({
|
||||
id: node.id,
|
||||
borderColor: this.getNodeBorderColor(node.type),
|
||||
borderWidth: 2,
|
||||
shadow: node.shadow || { enabled: false }
|
||||
}));
|
||||
|
||||
const edgeResets = newEdges.map(edge => ({
|
||||
id: edge.id,
|
||||
color: this.getEdgeColor(edge.metadata ? edge.metadata.confidence_score : 0.5),
|
||||
width: this.getEdgeWidth(edge.metadata ? edge.metadata.confidence_score : 0.5)
|
||||
}));
|
||||
|
||||
this.nodes.update(nodeResets);
|
||||
this.edges.update(edgeResets);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -460,6 +854,63 @@ class GraphManager {
|
||||
console.log('Graph stabilization complete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus view on specific node
|
||||
* @param {string} nodeId - Node to focus on
|
||||
*/
|
||||
focusOnNode(nodeId) {
|
||||
const nodePosition = this.network.getPositions([nodeId]);
|
||||
if (nodePosition[nodeId]) {
|
||||
this.network.moveTo({
|
||||
position: nodePosition[nodeId],
|
||||
scale: 1.5,
|
||||
animation: {
|
||||
duration: 1000,
|
||||
easingFunction: 'easeInOutQuart'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle physics simulation
|
||||
*/
|
||||
togglePhysics() {
|
||||
const currentPhysics = this.network.physics.physicsEnabled;
|
||||
this.network.setOptions({ physics: !currentPhysics });
|
||||
|
||||
const button = document.getElementById('graph-physics');
|
||||
if (button) {
|
||||
button.textContent = currentPhysics ? '[PHYSICS OFF]' : '[PHYSICS ON]';
|
||||
button.style.color = currentPhysics ? '#ff9900' : '#00ff41';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle node clustering
|
||||
*/
|
||||
toggleClustering() {
|
||||
// Simple clustering by node type
|
||||
const clusterOptionsByType = {
|
||||
joinCondition: (childOptions) => {
|
||||
return childOptions.type === 'domain';
|
||||
},
|
||||
clusterNodeProperties: {
|
||||
id: 'domain-cluster',
|
||||
borderWidth: 3,
|
||||
shape: 'database',
|
||||
label: 'Domains',
|
||||
color: '#00ff41'
|
||||
}
|
||||
};
|
||||
|
||||
if (this.network.clustering.isCluster('domain-cluster')) {
|
||||
this.network.clustering.openCluster('domain-cluster');
|
||||
} else {
|
||||
this.network.clustering.cluster(clusterOptionsByType);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit the view to show all nodes
|
||||
*/
|
||||
@ -516,24 +967,6 @@ class GraphManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup control event handlers
|
||||
*/
|
||||
setupEventHandlers() {
|
||||
// Reset view button
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const resetBtn = document.getElementById('reset-view');
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener('click', () => this.resetView());
|
||||
}
|
||||
|
||||
const fitBtn = document.getElementById('fit-view');
|
||||
if (fitBtn) {
|
||||
fitBtn.addEventListener('click', () => this.fitView());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get network statistics
|
||||
* @returns {Object} Statistics object
|
||||
|
@ -447,11 +447,19 @@ class DNSReconApp {
|
||||
try {
|
||||
console.log('Updating status display...');
|
||||
|
||||
// Update status text
|
||||
// Update status text with animation
|
||||
if (this.elements.scanStatus) {
|
||||
this.elements.scanStatus.textContent = this.formatStatus(status.status);
|
||||
console.log('Updated status display:', status.status);
|
||||
const formattedStatus = this.formatStatus(status.status);
|
||||
if (this.elements.scanStatus.textContent !== formattedStatus) {
|
||||
this.elements.scanStatus.textContent = formattedStatus;
|
||||
this.elements.scanStatus.classList.add('fade-in');
|
||||
setTimeout(() => this.elements.scanStatus.classList.remove('fade-in'), 300);
|
||||
}
|
||||
|
||||
// Add status-specific classes for styling
|
||||
this.elements.scanStatus.className = `status-value status-${status.status}`;
|
||||
}
|
||||
|
||||
if (this.elements.targetDisplay) {
|
||||
this.elements.targetDisplay.textContent = status.target_domain || 'None';
|
||||
}
|
||||
@ -465,9 +473,16 @@ class DNSReconApp {
|
||||
this.elements.indicatorsDisplay.textContent = status.indicators_processed || 0;
|
||||
}
|
||||
|
||||
// Update progress bar
|
||||
// Update progress bar with smooth animation
|
||||
if (this.elements.progressFill) {
|
||||
this.elements.progressFill.style.width = `${status.progress_percentage}%`;
|
||||
|
||||
// Add pulsing animation for active scans
|
||||
if (status.status === 'running') {
|
||||
this.elements.progressFill.parentElement.classList.add('scanning');
|
||||
} else {
|
||||
this.elements.progressFill.parentElement.classList.remove('scanning');
|
||||
}
|
||||
}
|
||||
|
||||
// Update session ID
|
||||
@ -492,12 +507,16 @@ class DNSReconApp {
|
||||
case 'running':
|
||||
this.setUIState('scanning');
|
||||
this.showSuccess('Scan is running');
|
||||
// Reset polling frequency for active scans
|
||||
this.pollFrequency = 2000;
|
||||
this.updateConnectionStatus('active');
|
||||
break;
|
||||
|
||||
case 'completed':
|
||||
this.setUIState('completed');
|
||||
this.stopPolling();
|
||||
this.showSuccess('Scan completed successfully');
|
||||
this.updateConnectionStatus('completed');
|
||||
// Force a final graph update
|
||||
console.log('Scan completed - forcing final graph update');
|
||||
setTimeout(() => this.updateGraph(), 100);
|
||||
@ -507,21 +526,55 @@ class DNSReconApp {
|
||||
this.setUIState('failed');
|
||||
this.stopPolling();
|
||||
this.showError('Scan failed');
|
||||
this.updateConnectionStatus('error');
|
||||
break;
|
||||
|
||||
case 'stopped':
|
||||
this.setUIState('stopped');
|
||||
this.stopPolling();
|
||||
this.showSuccess('Scan stopped');
|
||||
this.updateConnectionStatus('stopped');
|
||||
break;
|
||||
|
||||
case 'idle':
|
||||
this.setUIState('idle');
|
||||
this.stopPolling();
|
||||
this.updateConnectionStatus('idle');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection status indicator
|
||||
* @param {string} status - Connection status
|
||||
*/
|
||||
updateConnectionStatus(status) {
|
||||
if (!this.elements.connectionStatus) return;
|
||||
|
||||
const statusColors = {
|
||||
'idle': '#c7c7c7',
|
||||
'active': '#00ff41',
|
||||
'completed': '#00aa2e',
|
||||
'stopped': '#ff9900',
|
||||
'error': '#ff6b6b'
|
||||
};
|
||||
|
||||
this.elements.connectionStatus.style.backgroundColor = statusColors[status] || '#c7c7c7';
|
||||
|
||||
const statusText = this.elements.connectionStatus.parentElement?.querySelector('.status-text');
|
||||
if (statusText) {
|
||||
const statusTexts = {
|
||||
'idle': 'System Ready',
|
||||
'active': 'Scanning Active',
|
||||
'completed': 'Scan Complete',
|
||||
'stopped': 'Scan Stopped',
|
||||
'error': 'Connection Error'
|
||||
};
|
||||
|
||||
statusText.textContent = statusTexts[status] || 'System Online';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set UI state based on scan status
|
||||
* @param {string} state - UI state
|
||||
@ -532,10 +585,17 @@ class DNSReconApp {
|
||||
switch (state) {
|
||||
case 'scanning':
|
||||
this.isScanning = true;
|
||||
if (this.elements.startScan) this.elements.startScan.disabled = true;
|
||||
if (this.elements.stopScan) this.elements.stopScan.disabled = false;
|
||||
if (this.elements.startScan) {
|
||||
this.elements.startScan.disabled = true;
|
||||
this.elements.startScan.classList.add('loading');
|
||||
}
|
||||
if (this.elements.stopScan) {
|
||||
this.elements.stopScan.disabled = false;
|
||||
this.elements.stopScan.classList.remove('loading');
|
||||
}
|
||||
if (this.elements.targetDomain) this.elements.targetDomain.disabled = true;
|
||||
if (this.elements.maxDepth) this.elements.maxDepth.disabled = true;
|
||||
if (this.elements.configureApiKeys) this.elements.configureApiKeys.disabled = true;
|
||||
break;
|
||||
|
||||
case 'idle':
|
||||
@ -543,10 +603,17 @@ class DNSReconApp {
|
||||
case 'failed':
|
||||
case 'stopped':
|
||||
this.isScanning = false;
|
||||
if (this.elements.startScan) this.elements.startScan.disabled = false;
|
||||
if (this.elements.stopScan) this.elements.stopScan.disabled = true;
|
||||
if (this.elements.startScan) {
|
||||
this.elements.startScan.disabled = false;
|
||||
this.elements.startScan.classList.remove('loading');
|
||||
}
|
||||
if (this.elements.stopScan) {
|
||||
this.elements.stopScan.disabled = true;
|
||||
this.elements.stopScan.classList.add('loading');
|
||||
}
|
||||
if (this.elements.targetDomain) this.elements.targetDomain.disabled = false;
|
||||
if (this.elements.maxDepth) this.elements.maxDepth.disabled = false;
|
||||
if (this.elements.configureApiKeys) this.elements.configureApiKeys.disabled = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -580,20 +647,42 @@ class DNSReconApp {
|
||||
|
||||
for (const [name, info] of Object.entries(providers)) {
|
||||
const providerItem = document.createElement('div');
|
||||
providerItem.className = 'provider-item';
|
||||
providerItem.className = 'provider-item fade-in';
|
||||
|
||||
const status = info.enabled ? 'enabled' : 'disabled';
|
||||
const statusClass = info.enabled ? 'enabled' : 'disabled';
|
||||
let statusClass = 'disabled';
|
||||
let statusText = 'Disabled';
|
||||
|
||||
if (info.enabled) {
|
||||
statusClass = 'enabled';
|
||||
statusText = 'Enabled';
|
||||
} else if (info.requires_api_key) {
|
||||
statusClass = 'api-key-required';
|
||||
statusText = 'API Key Required';
|
||||
}
|
||||
|
||||
providerItem.innerHTML = `
|
||||
<div>
|
||||
<div class="provider-header">
|
||||
<div class="provider-name">${name.toUpperCase()}</div>
|
||||
<div class="provider-status ${statusClass}">${statusText}</div>
|
||||
</div>
|
||||
<div class="provider-stats">
|
||||
Requests: ${info.statistics.total_requests || 0} |
|
||||
Success Rate: ${(info.statistics.success_rate || 0).toFixed(1)}%
|
||||
<div class="provider-stat">
|
||||
<span class="provider-stat-label">Requests:</span>
|
||||
<span class="provider-stat-value">${info.statistics.total_requests || 0}</span>
|
||||
</div>
|
||||
<div class="provider-stat">
|
||||
<span class="provider-stat-label">Success Rate:</span>
|
||||
<span class="provider-stat-value">${(info.statistics.success_rate || 0).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class="provider-stat">
|
||||
<span class="provider-stat-label">Relationships:</span>
|
||||
<span class="provider-stat-value">${info.statistics.relationships_found || 0}</span>
|
||||
</div>
|
||||
<div class="provider-stat">
|
||||
<span class="provider-stat-label">Rate Limit:</span>
|
||||
<span class="provider-stat-value">${info.rate_limit}/min</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-status ${statusClass}">${status}</div>
|
||||
`;
|
||||
|
||||
this.elements.providerList.appendChild(providerItem);
|
||||
@ -614,7 +703,7 @@ class DNSReconApp {
|
||||
|
||||
let detailsHtml = '';
|
||||
detailsHtml += `<div class="detail-row"><span class="detail-label">Identifier:</span><span class="detail-value">${nodeId}</span></div>`;
|
||||
detailsHtml += `<div class="detail-row"><span class="detail-label">Type:</span><span class="detail-value">${node.metadata.type || 'Unknown'}</span></div>`;
|
||||
detailsHtml += `<div class="detail-row"><span class="detail-label">Type:</span><span class="detail-value">${node.metadata.type || node.type || 'Unknown'}</span></div>`;
|
||||
|
||||
if (node.metadata) {
|
||||
for (const [key, value] of Object.entries(node.metadata)) {
|
||||
@ -624,6 +713,12 @@ class DNSReconApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Add timestamps if available
|
||||
if (node.added_timestamp) {
|
||||
const addedDate = new Date(node.added_timestamp);
|
||||
detailsHtml += `<div class="detail-row"><span class="detail-label">Added:</span><span class="detail-value">${addedDate.toLocaleString()}</span></div>`;
|
||||
}
|
||||
|
||||
if (this.elements.modalDetails) {
|
||||
this.elements.modalDetails.innerHTML = detailsHtml;
|
||||
}
|
||||
@ -645,12 +740,24 @@ class DNSReconApp {
|
||||
* @returns {boolean} True if data has changed
|
||||
*/
|
||||
hasGraphChanged(graphData) {
|
||||
// Simple check based on node and edge counts
|
||||
// Simple check based on node and edge counts and timestamps
|
||||
const currentStats = this.graphManager.getStatistics();
|
||||
const newNodeCount = graphData.nodes ? graphData.nodes.length : 0;
|
||||
const newEdgeCount = graphData.edges ? graphData.edges.length : 0;
|
||||
|
||||
const changed = currentStats.nodeCount !== newNodeCount || currentStats.edgeCount !== newEdgeCount;
|
||||
// Check if counts changed
|
||||
const countsChanged = currentStats.nodeCount !== newNodeCount || currentStats.edgeCount !== newEdgeCount;
|
||||
|
||||
// Also check if we have new timestamp data
|
||||
const hasNewTimestamp = graphData.statistics &&
|
||||
graphData.statistics.last_modified &&
|
||||
graphData.statistics.last_modified !== this.lastGraphTimestamp;
|
||||
|
||||
if (hasNewTimestamp) {
|
||||
this.lastGraphTimestamp = graphData.statistics.last_modified;
|
||||
}
|
||||
|
||||
const changed = countsChanged || hasNewTimestamp;
|
||||
|
||||
console.log(`Graph change check: Current(${currentStats.nodeCount}n, ${currentStats.edgeCount}e) vs New(${newNodeCount}n, ${newEdgeCount}e) = ${changed}`);
|
||||
|
||||
@ -816,6 +923,10 @@ class DNSReconApp {
|
||||
this.showMessage(message, 'info');
|
||||
}
|
||||
|
||||
showWarning(message) {
|
||||
this.showMessage(message, 'warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
* @param {string} message - Error message
|
||||
@ -828,13 +939,7 @@ class DNSReconApp {
|
||||
* Show connection error
|
||||
*/
|
||||
showConnectionError() {
|
||||
if (this.elements.connectionStatus) {
|
||||
this.elements.connectionStatus.style.backgroundColor = '#ff6b6b';
|
||||
}
|
||||
const statusText = this.elements.connectionStatus?.parentElement?.querySelector('.status-text');
|
||||
if (statusText) {
|
||||
statusText.textContent = 'Connection Error';
|
||||
}
|
||||
this.updateConnectionStatus('error');
|
||||
}
|
||||
|
||||
/**
|
||||
@ -848,24 +953,12 @@ class DNSReconApp {
|
||||
// Create message element
|
||||
const messageElement = document.createElement('div');
|
||||
messageElement.className = `message-toast message-${type}`;
|
||||
messageElement.style.cssText = `
|
||||
background: ${this.getMessageColor(type)};
|
||||
color: #fff;
|
||||
padding: 12px 20px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 0.9rem;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
border-left: 4px solid ${this.getMessageBorderColor(type)};
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
`;
|
||||
|
||||
messageElement.innerHTML = `
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>${message}</span>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding: 12px 20px;">
|
||||
<span style="flex: 1;">${message}</span>
|
||||
<button onclick="this.parentElement.parentElement.remove()"
|
||||
style="background: none; border: none; color: #fff; cursor: pointer; font-size: 16px; margin-left: 10px;">×</button>
|
||||
style="background: none; border: none; color: #fff; cursor: pointer; font-size: 16px; margin-left: 10px; opacity: 0.7;">×</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -888,13 +981,8 @@ class DNSReconApp {
|
||||
}
|
||||
|
||||
// Update connection status to show activity
|
||||
if (type === 'success' && this.elements.connectionStatus) {
|
||||
this.elements.connectionStatus.style.backgroundColor = '#00ff41';
|
||||
setTimeout(() => {
|
||||
if (this.elements.connectionStatus) {
|
||||
this.elements.connectionStatus.style.backgroundColor = '#00ff41';
|
||||
}
|
||||
}, 2000);
|
||||
if (type === 'success' && this.consecutiveErrors === 0) {
|
||||
this.updateConnectionStatus(this.isScanning ? 'active' : 'idle');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -63,6 +63,10 @@
|
||||
<span class="btn-icon">[EXPORT]</span>
|
||||
<span>Download Results</span>
|
||||
</button>
|
||||
<button id="configure-api-keys" class="btn btn-secondary">
|
||||
<span class="btn-icon">[API]</span>
|
||||
<span>Configure API Keys</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
Loading…
x
Reference in New Issue
Block a user