Compare commits

...

21 Commits

Author SHA1 Message Date
overcuriousity
b20bfd2e36 attempt bugfix 2025-09-26 23:34:06 +02:00
overcuriousity
c3534868ad try fix bug 2025-09-26 23:26:21 +02:00
36c0bcdc03 Merge pull request 'gradient-test' (#4) from gradient-test into main
Reviewed-on: mstoeck3/dnsrecon#4
2025-09-24 11:16:26 +00:00
overcuriousity
ceb2d2fffc redundancy removal 2025-09-24 12:13:06 +02:00
overcuriousity
60cd649961 adjust graph context menu 2025-09-24 12:02:42 +02:00
overcuriousity
64309c53b7 fixes to export manager 2025-09-24 12:01:33 +02:00
overcuriousity
50fc5176a6 fix graph not reloading on completion in some cases 2025-09-24 11:48:11 +02:00
overcuriousity
3951b9e521 fix correlation provider issues 2025-09-24 11:36:27 +02:00
overcuriousity
897bb80183 gradient 2025-09-24 09:30:42 +02:00
overcuriousity
571912218e renaming 2025-09-22 22:45:46 +02:00
overcuriousity
5d1d249910 fix for performance optimizations 2025-09-22 15:37:33 +02:00
overcuriousity
52ea7acf04 performance optimization 2025-09-22 15:34:35 +02:00
overcuriousity
513eff12ef test fix stale 2025-09-22 11:22:39 +02:00
overcuriousity
4c361c144d readme 2025-09-20 22:05:14 +02:00
overcuriousity
b2629de055 large entity real fix 2025-09-20 21:42:32 +02:00
overcuriousity
b2c5d2331c work on large entity extraction 2025-09-20 20:56:31 +02:00
overcuriousity
602739246f refinements for correlations running logic 2025-09-20 20:31:56 +02:00
overcuriousity
4a82c279ef fix running of correlations after stop 2025-09-20 20:16:23 +02:00
overcuriousity
71a05f5b32 run correlation after stop 2025-09-20 18:48:47 +02:00
overcuriousity
1b0c630667 fic run correlation after stop request 2025-09-20 18:23:47 +02:00
overcuriousity
bcd79ae2f5 some fixes for UX, correlation engine 2025-09-20 18:19:10 +02:00
25 changed files with 1757 additions and 1713 deletions

View File

@ -1,5 +1,5 @@
# =============================================== # ===============================================
# DNSRecon Environment Variables # DNScope Environment Variables
# =============================================== # ===============================================
# Copy this file to .env and fill in your values. # Copy this file to .env and fill in your values.
@ -32,3 +32,5 @@ LARGE_ENTITY_THRESHOLD=100
MAX_RETRIES_PER_TARGET=8 MAX_RETRIES_PER_TARGET=8
# How long cached provider responses are stored (in hours). # How long cached provider responses are stored (in hours).
CACHE_TIMEOUT_HOURS=12 CACHE_TIMEOUT_HOURS=12
GRAPH_POLLING_NODE_THRESHOLD=100

View File

@ -1,16 +1,18 @@
# DNSRecon - Passive Infrastructure Reconnaissance Tool # DNScope - Passive Infrastructure Reconnaissance Tool
DNSRecon is an interactive, passive reconnaissance tool designed to map adversary infrastructure. It operates on a "free-by-default" model, ensuring core functionality without subscriptions, while allowing power users to enhance its capabilities with paid API keys. It is aimed at cybersecurity researchers, pentesters, and administrators who want to understand the public footprint of a target domain. DNScope is an interactive, passive reconnaissance tool designed to map adversary infrastructure. It operates on a "free-by-default" model, ensuring core functionality without subscriptions, while allowing power users to enhance its capabilities with paid API keys. It is aimed at cybersecurity researchers, pentesters, and administrators who want to understand the public footprint of a target domain.
**Repo Link:** [https://git.cc24.dev/mstoeck3/dnsrecon](https://git.cc24.dev/mstoeck3/dnsrecon) **Repo Link:** [https://github.com/overcuriousity/DNScope](https://github.com/overcuriousity/DNScope)
----- -----
## Concept and Philosophy ## Concept and Philosophy
The core philosophy of DNSRecon is to provide a comprehensive and accurate map of a target's infrastructure using only **passive data sources** by default. This means that, out of the box, DNSRecon will not send any traffic to the target's servers. Instead, it queries public and historical data sources to build a picture of the target's online presence. This approach is ideal for researchers and pentesters who want to gather intelligence without alerting the target, and for administrators who want to see what information about their own infrastructure is publicly available. The core philosophy of DNScope is to provide a comprehensive and accurate map of a target's infrastructure using only **passive data sources** by default. This means that, out of the box, DNScope will not send any traffic to the target's servers. Instead, it queries public and historical data sources to build a picture of the target's online presence. This approach is ideal for researchers and pentesters who want to gather intelligence without alerting the target, and for administrators who want to see what information about their own infrastructure is publicly available.
For power users who require more in-depth information, DNSRecon can be configured to use API keys for services like Shodan, which provides a wealth of information about internet-connected devices. However, this is an optional feature, and the core functionality of the tool will always remain free and passive. For power users who require more in-depth information, DNScope can be configured to use API keys for services like Shodan, which provides a wealth of information about internet-connected devices. However, this is an optional feature, and the core functionality of the tool will always remain free and passive.
-----
----- -----
@ -20,16 +22,18 @@ For power users who require more in-depth information, DNSRecon can be configure
* **In-Memory Graph Analysis**: Uses NetworkX for efficient relationship mapping. * **In-Memory Graph Analysis**: Uses NetworkX for efficient relationship mapping.
* **Real-Time Visualization**: The graph updates dynamically as the scan progresses. * **Real-Time Visualization**: The graph updates dynamically as the scan progresses.
* **Forensic Logging**: A complete audit trail of all reconnaissance activities is maintained. * **Forensic Logging**: A complete audit trail of all reconnaissance activities is maintained.
* **Confidence Scoring**: Relationships are weighted based on the reliability of the data source.
* **Session Management**: Supports concurrent user sessions with isolated scanner instances. * **Session Management**: Supports concurrent user sessions with isolated scanner instances.
* **Extensible Provider Architecture**: Easily add new data sources to expand the tool's capabilities. * **Extensible Provider Architecture**: Easily add new data sources to expand the tool's capabilities.
* **Web-Based UI**: An intuitive and interactive web interface for managing scans and visualizing results. * **Web-Based UI**: An intuitive and interactive web interface for managing scans and visualizing results.
* **Export Options**: Export scan results to JSON, a list of targets to a text file, or an executive summary.
* **API Key Management**: Securely manage API keys for various providers through the web interface.
* **Provider Management**: Enable or disable providers for the current session.
----- -----
## Technical Architecture ## Technical Architecture
DNSRecon is a web-based application built with a modern technology stack: DNScope is a web-based application built with a modern technology stack:
* **Backend**: The backend is a **Flask** application that provides a REST API for the frontend and manages the scanning process. * **Backend**: The backend is a **Flask** application that provides a REST API for the frontend and manages the scanning process.
* **Scanning Engine**: The core scanning engine is a multi-threaded Python application that uses a provider-based architecture to query different data sources. * **Scanning Engine**: The core scanning engine is a multi-threaded Python application that uses a provider-based architecture to query different data sources.
@ -41,7 +45,7 @@ DNSRecon is a web-based application built with a modern technology stack:
## Data Sources ## Data Sources
DNSRecon queries the following data sources: DNScope queries the following data sources:
* **DNS**: Standard DNS lookups (A, AAAA, CNAME, MX, NS, SOA, TXT). * **DNS**: Standard DNS lookups (A, AAAA, CNAME, MX, NS, SOA, TXT).
* **crt.sh**: A certificate transparency log that provides information about SSL/TLS certificates. * **crt.sh**: A certificate transparency log that provides information about SSL/TLS certificates.
@ -56,15 +60,43 @@ DNSRecon queries the following data sources:
* Python 3.8 or higher * Python 3.8 or higher
* A modern web browser with JavaScript enabled * A modern web browser with JavaScript enabled
* A Linux host for running the application * A Linux host for running the application
* Redis Server
### 1\. Clone the Project ### 1\. Install Redis
It is recommended to install Redis from the official repositories.
**On Debian/Ubuntu:**
```bash ```bash
git clone https://git.cc24.dev/mstoeck3/dnsrecon sudo apt-get update
cd dnsrecon sudo apt-get install redis-server
``` ```
### 2\. Install Python Dependencies **On CentOS/RHEL:**
```bash
sudo yum install redis
sudo systemctl start redis
sudo systemctl enable redis
```
You can verify that Redis is running with the following command:
```bash
redis-cli ping
```
You should see `PONG` as the response.
### 2\. Clone the Project
```bash
git clone https://github.com/overcuriousity/DNScope
cd DNScope
```
### 3\. Install Python Dependencies
It is highly recommended to use a virtual environment: It is highly recommended to use a virtual environment:
@ -86,10 +118,11 @@ The `requirements.txt` file contains the following dependencies:
* gunicorn * gunicorn
* redis * redis
* python-dotenv * python-dotenv
* psycopg2-binary
### 3\. Configure the Application ### 4\. Configure the Application
DNSRecon is configured using a `.env` file. You can copy the provided example file and edit it to suit your needs: DNScope is configured using a `.env` file. You can copy the provided example file and edit it to suit your needs:
```bash ```bash
cp .env.example .env cp .env.example .env
@ -133,30 +166,30 @@ gunicorn --workers 4 --bind 0.0.0.0:5000 app:app
## Systemd Service ## Systemd Service
To run DNSRecon as a service that starts automatically on boot, you can use `systemd`. To run DNScope as a service that starts automatically on boot, you can use `systemd`.
### 1\. Create a `.service` file ### 1\. Create a `.service` file
Create a new service file in `/etc/systemd/system/`: Create a new service file in `/etc/systemd/system/`:
```bash ```bash
sudo nano /etc/systemd/system/dnsrecon.service sudo nano /etc/systemd/system/DNScope.service
``` ```
### 2\. Add the Service Configuration ### 2\. Add the Service Configuration
Paste the following configuration into the file. **Remember to replace `/path/to/your/dnsrecon` and `your_user` with your actual project path and username.** Paste the following configuration into the file. **Remember to replace `/path/to/your/DNScope` and `your_user` with your actual project path and username.**
```ini ```ini
[Unit] [Unit]
Description=DNSRecon Application Description=DNScope Application
After=network.target After=network.target
[Service] [Service]
User=your_user User=your_user
Group=your_user Group=your_user
WorkingDirectory=/path/to/your/dnsrecon WorkingDirectory=/path/to/your/DNScope
ExecStart=/path/to/your/dnsrecon/venv/bin/gunicorn --workers 4 --bind 0.0.0.0:5000 app:app ExecStart=/path/to/your/DNScope/venv/bin/gunicorn --workers 4 --bind 0.0.0.0:5000 app:app
Restart=always Restart=always
Environment="SECRET_KEY=your-super-secret-and-random-key" Environment="SECRET_KEY=your-super-secret-and-random-key"
Environment="FLASK_ENV=production" Environment="FLASK_ENV=production"
@ -173,14 +206,14 @@ Reload the `systemd` daemon, enable the service to start on boot, and then start
```bash ```bash
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable dnsrecon.service sudo systemctl enable DNScope.service
sudo systemctl start dnsrecon.service sudo systemctl start DNScope.service
``` ```
You can check the status of the service at any time with: You can check the status of the service at any time with:
```bash ```bash
sudo systemctl status dnsrecon.service sudo systemctl status DNScope.service
``` ```
----- -----
@ -210,14 +243,14 @@ rm -rf cache/*
### 4\. Restart the Service ### 4\. Restart the Service
```bash ```bash
sudo systemctl restart dnsrecon.service sudo systemctl restart DNScope.service
``` ```
----- -----
## Extensibility ## Extensibility
DNSRecon is designed to be extensible, and adding new providers is a straightforward process. To add a new provider, you will need to create a new Python file in the `providers` directory that inherits from the `BaseProvider` class. The new provider will need to implement the following methods: DNScope is designed to be extensible, and adding new providers is a straightforward process. To add a new provider, you will need to create a new Python file in the `providers` directory that inherits from the `BaseProvider` class. The new provider will need to implement the following methods:
* `get_name()`: Return the name of the provider. * `get_name()`: Return the name of the provider.
* `get_display_name()`: Return a display-friendly name for the provider. * `get_display_name()`: Return a display-friendly name for the provider.

86
app.py
View File

@ -1,8 +1,9 @@
# dnsrecon-reduced/app.py # DNScope-reduced/app.py
""" """
Flask application entry point for DNSRecon web interface. Flask application entry point for DNScope web interface.
Provides REST API endpoints and serves the web interface with user session support. Provides REST API endpoints and serves the web interface with user session support.
UPDATED: Added /api/config endpoint for graph polling optimization settings.
""" """
import json import json
@ -28,7 +29,7 @@ def get_user_scanner():
""" """
Retrieves the scanner for the current session, or creates a new one if none exists. Retrieves the scanner for the current session, or creates a new one if none exists.
""" """
current_flask_session_id = session.get('dnsrecon_session_id') current_flask_session_id = session.get('DNScope_session_id')
if current_flask_session_id: if current_flask_session_id:
existing_scanner = session_manager.get_session(current_flask_session_id) existing_scanner = session_manager.get_session(current_flask_session_id)
@ -41,7 +42,7 @@ def get_user_scanner():
if not new_scanner: if not new_scanner:
raise Exception("Failed to create new scanner session") raise Exception("Failed to create new scanner session")
session['dnsrecon_session_id'] = new_session_id session['DNScope_session_id'] = new_session_id
session.permanent = True session.permanent = True
return new_session_id, new_scanner return new_session_id, new_scanner
@ -53,6 +54,21 @@ def index():
return render_template('index.html') return render_template('index.html')
@app.route('/api/config', methods=['GET'])
def get_config():
"""Get configuration settings for frontend."""
try:
return jsonify({
'success': True,
'config': {
'graph_polling_node_threshold': config.graph_polling_node_threshold
}
})
except Exception as e:
traceback.print_exc()
return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
@app.route('/api/scan/start', methods=['POST']) @app.route('/api/scan/start', methods=['POST'])
def start_scan(): def start_scan():
""" """
@ -187,7 +203,9 @@ def get_graph_data():
@app.route('/api/graph/large-entity/extract', methods=['POST']) @app.route('/api/graph/large-entity/extract', methods=['POST'])
def extract_from_large_entity(): def extract_from_large_entity():
"""Extract a node from a large entity.""" """
FIXED: Extract a node from a large entity with proper error handling.
"""
try: try:
data = request.get_json() data = request.get_json()
large_entity_id = data.get('large_entity_id') large_entity_id = data.get('large_entity_id')
@ -200,17 +218,66 @@ def extract_from_large_entity():
if not scanner: if not scanner:
return jsonify({'success': False, 'error': 'No active session found'}), 404 return jsonify({'success': False, 'error': 'No active session found'}), 404
# FIXED: Check if node exists and provide better error messages
if not scanner.graph.graph.has_node(node_id):
return jsonify({
'success': False,
'error': f'Node {node_id} not found in graph'
}), 404
# FIXED: Check if node is actually part of the large entity
node_data = scanner.graph.graph.nodes[node_id]
metadata = node_data.get('metadata', {})
current_large_entity = metadata.get('large_entity_id')
if not current_large_entity:
return jsonify({
'success': False,
'error': f'Node {node_id} is not part of any large entity'
}), 400
if current_large_entity != large_entity_id:
return jsonify({
'success': False,
'error': f'Node {node_id} belongs to large entity {current_large_entity}, not {large_entity_id}'
}), 400
# FIXED: Check if large entity exists
if not scanner.graph.graph.has_node(large_entity_id):
return jsonify({
'success': False,
'error': f'Large entity {large_entity_id} not found'
}), 404
# Perform the extraction
success = scanner.extract_node_from_large_entity(large_entity_id, node_id) success = scanner.extract_node_from_large_entity(large_entity_id, node_id)
if success: if success:
# Force immediate session state update
session_manager.update_session_scanner(user_session_id, scanner) session_manager.update_session_scanner(user_session_id, scanner)
return jsonify({'success': True, 'message': f'Node {node_id} extracted successfully.'})
else:
return jsonify({'success': False, 'error': f'Failed to extract node {node_id}.'}), 500
return jsonify({
'success': True,
'message': f'Node {node_id} extracted successfully from {large_entity_id}.',
'extracted_node': node_id,
'large_entity': large_entity_id
})
else:
# This should not happen with the improved checks above, but handle it gracefully
return jsonify({
'success': False,
'error': f'Failed to extract node {node_id} from {large_entity_id}. Node may have already been extracted.'
}), 409
except json.JSONDecodeError:
return jsonify({'success': False, 'error': 'Invalid JSON in request body'}), 400
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500 return jsonify({
'success': False,
'error': f'Internal server error: {str(e)}',
'error_type': type(e).__name__
}), 500
@app.route('/api/graph/node/<node_id>', methods=['DELETE']) @app.route('/api/graph/node/<node_id>', methods=['DELETE'])
def delete_graph_node(node_id): def delete_graph_node(node_id):
@ -265,7 +332,6 @@ def revert_graph_action():
scanner.graph.add_edge( scanner.graph.add_edge(
source_id=edge['from'], target_id=edge['to'], source_id=edge['from'], target_id=edge['to'],
relationship_type=edge['metadata']['relationship_type'], relationship_type=edge['metadata']['relationship_type'],
confidence_score=edge['metadata']['confidence_score'],
source_provider=edge['metadata']['source_provider'], source_provider=edge['metadata']['source_provider'],
raw_data=edge.get('raw_data', {}) raw_data=edge.get('raw_data', {})
) )

View File

@ -1,7 +1,7 @@
# dnsrecon-reduced/config.py # DNScope-reduced/config.py
""" """
Configuration management for DNSRecon tool. Configuration management for DNScope tool.
Handles API key storage, rate limiting, and default settings. Handles API key storage, rate limiting, and default settings.
""" """
@ -13,7 +13,7 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
class Config: class Config:
"""Configuration manager for DNSRecon application.""" """Configuration manager for DNScope application."""
def __init__(self): def __init__(self):
"""Initialize configuration with default values.""" """Initialize configuration with default values."""
@ -26,6 +26,9 @@ class Config:
self.large_entity_threshold = 100 self.large_entity_threshold = 100
self.max_retries_per_target = 8 self.max_retries_per_target = 8
# --- Graph Polling Performance Settings ---
self.graph_polling_node_threshold = 100 # Stop graph auto-polling above this many nodes
# --- Provider Caching Settings --- # --- Provider Caching Settings ---
self.cache_timeout_hours = 6 # Provider-specific cache timeout self.cache_timeout_hours = 6 # Provider-specific cache timeout
@ -72,6 +75,9 @@ class Config:
self.max_retries_per_target = int(os.getenv('MAX_RETRIES_PER_TARGET', self.max_retries_per_target)) self.max_retries_per_target = int(os.getenv('MAX_RETRIES_PER_TARGET', self.max_retries_per_target))
self.cache_timeout_hours = int(os.getenv('CACHE_TIMEOUT_HOURS', self.cache_timeout_hours)) self.cache_timeout_hours = int(os.getenv('CACHE_TIMEOUT_HOURS', self.cache_timeout_hours))
# Override graph polling threshold from environment
self.graph_polling_node_threshold = int(os.getenv('GRAPH_POLLING_NODE_THRESHOLD', self.graph_polling_node_threshold))
# Override Flask and session settings # Override Flask and session settings
self.flask_host = os.getenv('FLASK_HOST', self.flask_host) self.flask_host = os.getenv('FLASK_HOST', self.flask_host)
self.flask_port = int(os.getenv('FLASK_PORT', self.flask_port)) self.flask_port = int(os.getenv('FLASK_PORT', self.flask_port))

View File

@ -1,5 +1,5 @@
""" """
Core modules for DNSRecon passive reconnaissance tool. Core modules for DNScope passive reconnaissance tool.
Contains graph management, scanning orchestration, and forensic logging. Contains graph management, scanning orchestration, and forensic logging.
""" """

View File

@ -1,8 +1,8 @@
# dnsrecon-reduced/core/graph_manager.py # DNScope-reduced/core/graph_manager.py
""" """
Graph data model for DNSRecon using NetworkX. Graph data model for DNScope using NetworkX.
Manages in-memory graph storage with confidence scoring and forensic metadata. Manages in-memory graph storage with forensic metadata.
Now fully compatible with the unified ProviderResult data model. Now fully compatible with the unified ProviderResult data model.
UPDATED: Fixed correlation exclusion keys to match actual attribute names. UPDATED: Fixed correlation exclusion keys to match actual attribute names.
UPDATED: Removed export_json() method - now handled by ExportManager. UPDATED: Removed export_json() method - now handled by ExportManager.
@ -30,8 +30,8 @@ class NodeType(Enum):
class GraphManager: class GraphManager:
""" """
Thread-safe graph manager for DNSRecon infrastructure mapping. Thread-safe graph manager for DNScope infrastructure mapping.
Uses NetworkX for in-memory graph storage with confidence scoring. Uses NetworkX for in-memory graph storage.
Compatible with unified ProviderResult data model. Compatible with unified ProviderResult data model.
""" """
@ -83,7 +83,7 @@ class GraphManager:
return is_new_node return is_new_node
def add_edge(self, source_id: str, target_id: str, relationship_type: str, def add_edge(self, source_id: str, target_id: str, relationship_type: str,
confidence_score: float = 0.5, source_provider: str = "unknown", source_provider: str = "unknown",
raw_data: Optional[Dict[str, Any]] = None) -> bool: raw_data: Optional[Dict[str, Any]] = None) -> bool:
""" """
UPDATED: Add or update an edge between two nodes with raw relationship labels. UPDATED: Add or update an edge between two nodes with raw relationship labels.
@ -91,23 +91,13 @@ class GraphManager:
if not self.graph.has_node(source_id) or not self.graph.has_node(target_id): if not self.graph.has_node(source_id) or not self.graph.has_node(target_id):
return False return False
new_confidence = confidence_score
# UPDATED: Use raw relationship type - no formatting # UPDATED: Use raw relationship type - no formatting
edge_label = relationship_type edge_label = relationship_type
if self.graph.has_edge(source_id, target_id):
# If edge exists, update confidence if the new score is higher.
if new_confidence > self.graph.edges[source_id, target_id].get('confidence_score', 0):
self.graph.edges[source_id, target_id]['confidence_score'] = new_confidence
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
# Add a new edge with raw attributes # Add a new edge with raw attributes
self.graph.add_edge(source_id, target_id, self.graph.add_edge(source_id, target_id,
relationship_type=edge_label, relationship_type=edge_label,
confidence_score=new_confidence,
source_provider=source_provider, source_provider=source_provider,
discovery_timestamp=datetime.now(timezone.utc).isoformat(), discovery_timestamp=datetime.now(timezone.utc).isoformat(),
raw_data=raw_data or {}) raw_data=raw_data or {})
@ -137,11 +127,6 @@ class GraphManager:
"""Get all nodes of a specific type.""" """Get all nodes of a specific type."""
return [n for n, d in self.graph.nodes(data=True) if d.get('type') == node_type.value] return [n for n, d in self.graph.nodes(data=True) if d.get('type') == node_type.value]
def get_high_confidence_edges(self, min_confidence: float = 0.8) -> List[Tuple[str, str, Dict]]:
"""Get edges with confidence score above a given threshold."""
return [(u, v, d) for u, v, d in self.graph.edges(data=True)
if d.get('confidence_score', 0) >= min_confidence]
def get_graph_data(self) -> Dict[str, Any]: def get_graph_data(self) -> Dict[str, Any]:
""" """
Export graph data formatted for frontend visualization. Export graph data formatted for frontend visualization.
@ -177,9 +162,9 @@ class GraphManager:
'from': source, 'from': source,
'to': target, 'to': target,
'label': attrs.get('relationship_type', ''), 'label': attrs.get('relationship_type', ''),
'confidence_score': attrs.get('confidence_score', 0),
'source_provider': attrs.get('source_provider', ''), 'source_provider': attrs.get('source_provider', ''),
'discovery_timestamp': attrs.get('discovery_timestamp') 'discovery_timestamp': attrs.get('discovery_timestamp'),
'raw_data': attrs.get('raw_data', {})
}) })
return { return {
@ -188,24 +173,6 @@ class GraphManager:
'statistics': self.get_statistics()['basic_metrics'] 'statistics': self.get_statistics()['basic_metrics']
} }
def _get_confidence_distribution(self) -> Dict[str, int]:
"""Get distribution of edge confidence scores with empty graph handling."""
distribution = {'high': 0, 'medium': 0, 'low': 0}
# FIXED: Handle empty graph case
if self.get_edge_count() == 0:
return distribution
for _, _, data in self.graph.edges(data=True):
confidence = data.get('confidence_score', 0)
if confidence >= 0.8:
distribution['high'] += 1
elif confidence >= 0.6:
distribution['medium'] += 1
else:
distribution['low'] += 1
return distribution
def get_statistics(self) -> Dict[str, Any]: def get_statistics(self) -> Dict[str, Any]:
"""Get comprehensive statistics about the graph with proper empty graph handling.""" """Get comprehensive statistics about the graph with proper empty graph handling."""
@ -222,7 +189,6 @@ class GraphManager:
}, },
'node_type_distribution': {}, 'node_type_distribution': {},
'relationship_type_distribution': {}, 'relationship_type_distribution': {},
'confidence_distribution': self._get_confidence_distribution(),
'provider_distribution': {} 'provider_distribution': {}
} }

View File

@ -1,4 +1,4 @@
# dnsrecon/core/logger.py # DNScope/core/logger.py
import logging import logging
import threading import threading
@ -30,7 +30,6 @@ class RelationshipDiscovery:
source_node: str source_node: str
target_node: str target_node: str
relationship_type: str relationship_type: str
confidence_score: float
provider: str provider: str
raw_data: Dict[str, Any] raw_data: Dict[str, Any]
discovery_method: str discovery_method: str
@ -38,7 +37,7 @@ class RelationshipDiscovery:
class ForensicLogger: class ForensicLogger:
""" """
Thread-safe forensic logging system for DNSRecon. Thread-safe forensic logging system for DNScope.
Maintains detailed audit trail of all reconnaissance activities. Maintains detailed audit trail of all reconnaissance activities.
""" """
@ -66,7 +65,7 @@ class ForensicLogger:
} }
# Configure standard logger # Configure standard logger
self.logger = logging.getLogger(f'dnsrecon.{self.session_id}') self.logger = logging.getLogger(f'DNScope.{self.session_id}')
self.logger.setLevel(logging.INFO) self.logger.setLevel(logging.INFO)
# Create formatter for structured logging # Create formatter for structured logging
@ -94,7 +93,7 @@ class ForensicLogger:
"""Restore ForensicLogger after unpickling by reconstructing logger.""" """Restore ForensicLogger after unpickling by reconstructing logger."""
self.__dict__.update(state) self.__dict__.update(state)
# Re-initialize the 'logger' attribute # Re-initialize the 'logger' attribute
self.logger = logging.getLogger(f'dnsrecon.{self.session_id}') self.logger = logging.getLogger(f'DNScope.{self.session_id}')
self.logger.setLevel(logging.INFO) self.logger.setLevel(logging.INFO)
formatter = logging.Formatter( formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s' '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
@ -107,7 +106,7 @@ class ForensicLogger:
def _generate_session_id(self) -> str: def _generate_session_id(self) -> str:
"""Generate unique session identifier.""" """Generate unique session identifier."""
return f"dnsrecon_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}" return f"DNScope_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}"
def log_api_request(self, provider: str, url: str, method: str = "GET", def log_api_request(self, provider: str, url: str, method: str = "GET",
status_code: Optional[int] = None, status_code: Optional[int] = None,
@ -157,7 +156,7 @@ class ForensicLogger:
self.logger.info(f"API Request - {provider}: {url} - Status: {status_code}") self.logger.info(f"API Request - {provider}: {url} - Status: {status_code}")
def log_relationship_discovery(self, source_node: str, target_node: str, def log_relationship_discovery(self, source_node: str, target_node: str,
relationship_type: str, confidence_score: float, relationship_type: str,
provider: str, raw_data: Dict[str, Any], provider: str, raw_data: Dict[str, Any],
discovery_method: str) -> None: discovery_method: str) -> None:
""" """
@ -167,7 +166,6 @@ class ForensicLogger:
source_node: Source node identifier source_node: Source node identifier
target_node: Target node identifier target_node: Target node identifier
relationship_type: Type of relationship (e.g., 'SAN', 'A_Record') relationship_type: Type of relationship (e.g., 'SAN', 'A_Record')
confidence_score: Confidence score (0.0 to 1.0)
provider: Provider that discovered this relationship provider: Provider that discovered this relationship
raw_data: Raw data from provider response raw_data: Raw data from provider response
discovery_method: Method used to discover relationship discovery_method: Method used to discover relationship
@ -177,7 +175,6 @@ class ForensicLogger:
source_node=source_node, source_node=source_node,
target_node=target_node, target_node=target_node,
relationship_type=relationship_type, relationship_type=relationship_type,
confidence_score=confidence_score,
provider=provider, provider=provider,
raw_data=raw_data, raw_data=raw_data,
discovery_method=discovery_method discovery_method=discovery_method
@ -188,7 +185,7 @@ class ForensicLogger:
self.logger.info( self.logger.info(
f"Relationship Discovered - {source_node} -> {target_node} " f"Relationship Discovered - {source_node} -> {target_node} "
f"({relationship_type}) - Confidence: {confidence_score:.2f} - Provider: {provider}" f"({relationship_type}) - Provider: {provider}"
) )
def log_scan_start(self, target_domain: str, recursion_depth: int, def log_scan_start(self, target_domain: str, recursion_depth: int,
@ -197,13 +194,11 @@ class ForensicLogger:
self.logger.info(f"Scan Started - Target: {target_domain}, Depth: {recursion_depth}") self.logger.info(f"Scan Started - Target: {target_domain}, Depth: {recursion_depth}")
self.logger.info(f"Enabled Providers: {', '.join(enabled_providers)}") self.logger.info(f"Enabled Providers: {', '.join(enabled_providers)}")
self.session_metadata['target_domains'].update(target_domain) self.session_metadata['target_domains'].add(target_domain)
def log_scan_complete(self) -> None: def log_scan_complete(self) -> None:
"""Log the completion of a reconnaissance scan.""" """Log the completion of a reconnaissance scan."""
self.session_metadata['end_time'] = datetime.now(timezone.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'])
self.logger.info(f"Scan Complete - Session: {self.session_id}") self.logger.info(f"Scan Complete - Session: {self.session_id}")
@ -214,8 +209,12 @@ class ForensicLogger:
Returns: Returns:
Dictionary containing complete session audit trail Dictionary containing complete session audit trail
""" """
session_metadata_export = self.session_metadata.copy()
session_metadata_export['providers_used'] = list(session_metadata_export['providers_used'])
session_metadata_export['target_domains'] = list(session_metadata_export['target_domains'])
return { return {
'session_metadata': self.session_metadata.copy(), 'session_metadata': session_metadata_export,
'api_requests': [asdict(req) for req in self.api_requests], 'api_requests': [asdict(req) for req in self.api_requests],
'relationships': [asdict(rel) for rel in self.relationships], 'relationships': [asdict(rel) for rel in self.relationships],
'export_timestamp': datetime.now(timezone.utc).isoformat() 'export_timestamp': datetime.now(timezone.utc).isoformat()
@ -238,7 +237,6 @@ class ForensicLogger:
'successful_requests': len([req for req in provider_requests if req.error is None]), 'successful_requests': len([req for req in provider_requests if req.error is None]),
'failed_requests': len([req for req in provider_requests if req.error is not None]), 'failed_requests': len([req for req in provider_requests if req.error is not None]),
'relationships_discovered': len(provider_relationships), 'relationships_discovered': len(provider_relationships),
'avg_confidence': sum(rel.confidence_score for rel in provider_relationships) / len(provider_relationships) if provider_relationships else 0
} }
return { return {

View File

@ -1,7 +1,7 @@
# dnsrecon-reduced/core/provider_result.py # DNScope-reduced/core/provider_result.py
""" """
Unified data model for DNSRecon passive reconnaissance. Unified data model for DNScope passive reconnaissance.
Standardizes the data structure across all providers to ensure consistent processing. Standardizes the data structure across all providers to ensure consistent processing.
""" """
@ -18,33 +18,19 @@ class StandardAttribute:
value: Any value: Any
type: str type: str
provider: str provider: str
confidence: float
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
metadata: Optional[Dict[str, Any]] = field(default_factory=dict) metadata: Optional[Dict[str, Any]] = field(default_factory=dict)
def __post_init__(self):
"""Validate the attribute after initialization."""
if not isinstance(self.confidence, (int, float)) or not 0.0 <= self.confidence <= 1.0:
raise ValueError(f"Confidence must be between 0.0 and 1.0, got {self.confidence}")
@dataclass @dataclass
class Relationship: class Relationship:
"""A unified data structure for a directional link between two nodes.""" """A unified data structure for a directional link between two nodes."""
source_node: str source_node: str
target_node: str target_node: str
relationship_type: str relationship_type: str
confidence: float
provider: str provider: str
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
raw_data: Optional[Dict[str, Any]] = field(default_factory=dict) raw_data: Optional[Dict[str, Any]] = field(default_factory=dict)
def __post_init__(self):
"""Validate the relationship after initialization."""
if not isinstance(self.confidence, (int, float)) or not 0.0 <= self.confidence <= 1.0:
raise ValueError(f"Confidence must be between 0.0 and 1.0, got {self.confidence}")
@dataclass @dataclass
class ProviderResult: class ProviderResult:
"""A container for all data returned by a provider from a single query.""" """A container for all data returned by a provider from a single query."""
@ -52,8 +38,7 @@ class ProviderResult:
relationships: List[Relationship] = field(default_factory=list) relationships: List[Relationship] = field(default_factory=list)
def add_attribute(self, target_node: str, name: str, value: Any, attr_type: str, def add_attribute(self, target_node: str, name: str, value: Any, attr_type: str,
provider: str, confidence: float = 0.8, provider: str, metadata: Optional[Dict[str, Any]] = None) -> None:
metadata: Optional[Dict[str, Any]] = None) -> None:
"""Helper method to add an attribute to the result.""" """Helper method to add an attribute to the result."""
self.attributes.append(StandardAttribute( self.attributes.append(StandardAttribute(
target_node=target_node, target_node=target_node,
@ -61,19 +46,16 @@ class ProviderResult:
value=value, value=value,
type=attr_type, type=attr_type,
provider=provider, provider=provider,
confidence=confidence,
metadata=metadata or {} metadata=metadata or {}
)) ))
def add_relationship(self, source_node: str, target_node: str, relationship_type: str, def add_relationship(self, source_node: str, target_node: str, relationship_type: str,
provider: str, confidence: float = 0.8, provider: str, raw_data: Optional[Dict[str, Any]] = None) -> None:
raw_data: Optional[Dict[str, Any]] = None) -> None:
"""Helper method to add a relationship to the result.""" """Helper method to add a relationship to the result."""
self.relationships.append(Relationship( self.relationships.append(Relationship(
source_node=source_node, source_node=source_node,
target_node=target_node, target_node=target_node,
relationship_type=relationship_type, relationship_type=relationship_type,
confidence=confidence,
provider=provider, provider=provider,
raw_data=raw_data or {} raw_data=raw_data or {}
)) ))

View File

@ -1,28 +1,145 @@
# dnsrecon-reduced/core/rate_limiter.py # DNScope-reduced/core/rate_limiter.py
import time import time
import logging
class GlobalRateLimiter: class GlobalRateLimiter:
"""
FIXED: Improved rate limiter with better cleanup and error handling.
Prevents accumulation of stale entries that cause infinite retry loops.
"""
def __init__(self, redis_client): def __init__(self, redis_client):
self.redis = redis_client self.redis = redis_client
self.logger = logging.getLogger('DNScope.rate_limiter')
# Track last cleanup times to avoid excessive Redis operations
self._last_cleanup = {}
def is_rate_limited(self, key, limit, period): def is_rate_limited(self, key, limit, period):
""" """
Check if a key is rate-limited. FIXED: Check if a key is rate-limited with improved cleanup and error handling.
Args:
key: Rate limit key (e.g., provider name)
limit: Maximum requests allowed
period: Time period in seconds (60 for per-minute)
Returns:
bool: True if rate limited, False otherwise
"""
if limit <= 0:
# Rate limit of 0 or negative means no limiting
return False
now = time.time()
rate_key = f"rate_limit:{key}"
try:
# FIXED: More aggressive cleanup to prevent accumulation
# Only clean up if we haven't cleaned recently (every 10 seconds max)
should_cleanup = (
rate_key not in self._last_cleanup or
now - self._last_cleanup.get(rate_key, 0) > 10
)
if should_cleanup:
# Remove entries older than the period
removed_count = self.redis.zremrangebyscore(rate_key, 0, now - period)
self._last_cleanup[rate_key] = now
if removed_count > 0:
self.logger.debug(f"Rate limiter cleaned up {removed_count} old entries for {key}")
# Get current count
current_count = self.redis.zcard(rate_key)
if current_count >= limit:
self.logger.debug(f"Rate limited: {key} has {current_count}/{limit} requests in period")
return True
# Add new timestamp with error handling
try:
# Use pipeline for atomic operations
pipe = self.redis.pipeline()
pipe.zadd(rate_key, {str(now): now})
pipe.expire(rate_key, int(period * 2)) # Set TTL to 2x period for safety
pipe.execute()
except Exception as e:
self.logger.warning(f"Failed to record rate limit entry for {key}: {e}")
# Don't block the request if we can't record it
return False
return False
except Exception as e:
self.logger.error(f"Rate limiter error for {key}: {e}")
# FIXED: On Redis errors, don't block requests to avoid infinite loops
return False
def get_rate_limit_status(self, key, limit, period):
"""
Get detailed rate limit status for debugging.
Returns:
dict: Status information including current count, limit, and time to reset
""" """
now = time.time() now = time.time()
key = f"rate_limit:{key}" rate_key = f"rate_limit:{key}"
# Remove old timestamps try:
self.redis.zremrangebyscore(key, 0, now - period) current_count = self.redis.zcard(rate_key)
# Check the count # Get oldest entry to calculate reset time
count = self.redis.zcard(key) oldest_entries = self.redis.zrange(rate_key, 0, 0, withscores=True)
if count >= limit: time_to_reset = 0
if oldest_entries:
oldest_time = oldest_entries[0][1]
time_to_reset = max(0, period - (now - oldest_time))
return {
'key': key,
'current_count': current_count,
'limit': limit,
'period': period,
'is_limited': current_count >= limit,
'time_to_reset': time_to_reset
}
except Exception as e:
self.logger.error(f"Failed to get rate limit status for {key}: {e}")
return {
'key': key,
'current_count': 0,
'limit': limit,
'period': period,
'is_limited': False,
'time_to_reset': 0,
'error': str(e)
}
def reset_rate_limit(self, key):
"""
ADDED: Reset rate limit for a specific key (useful for debugging).
"""
rate_key = f"rate_limit:{key}"
try:
deleted = self.redis.delete(rate_key)
self.logger.info(f"Reset rate limit for {key} (deleted: {deleted})")
return True return True
except Exception as e:
self.logger.error(f"Failed to reset rate limit for {key}: {e}")
return False
# Add new timestamp def cleanup_all_rate_limits(self):
self.redis.zadd(key, {now: now}) """
self.redis.expire(key, period) ADDED: Clean up all rate limit entries (useful for maintenance).
"""
return False try:
keys = self.redis.keys("rate_limit:*")
if keys:
deleted = self.redis.delete(*keys)
self.logger.info(f"Cleaned up {deleted} rate limit keys")
return deleted
return 0
except Exception as e:
self.logger.error(f"Failed to cleanup rate limits: {e}")
return 0

View File

@ -1,4 +1,4 @@
# dnsrecon-reduced/core/scanner.py # DNScope-reduced/core/scanner.py
import threading import threading
import traceback import traceback
@ -27,6 +27,7 @@ class ScanStatus:
"""Enumeration of scan statuses.""" """Enumeration of scan statuses."""
IDLE = "idle" IDLE = "idle"
RUNNING = "running" RUNNING = "running"
FINALIZING = "finalizing" # New state for post-scan analysis
COMPLETED = "completed" COMPLETED = "completed"
FAILED = "failed" FAILED = "failed"
STOPPED = "stopped" STOPPED = "stopped"
@ -34,7 +35,7 @@ class ScanStatus:
class Scanner: class Scanner:
""" """
Main scanning orchestrator for DNSRecon passive reconnaissance. Main scanning orchestrator for DNScope passive reconnaissance.
UNIFIED: Combines comprehensive features with improved display formatting. UNIFIED: Combines comprehensive features with improved display formatting.
""" """
@ -192,6 +193,8 @@ class Scanner:
print(f"=== INITIALIZING PROVIDERS FROM {provider_dir} ===") print(f"=== INITIALIZING PROVIDERS FROM {provider_dir} ===")
correlation_provider_instance = None
for filename in os.listdir(provider_dir): for filename in os.listdir(provider_dir):
if filename.endswith('_provider.py') and not filename.startswith('base'): if filename.endswith('_provider.py') and not filename.startswith('base'):
module_name = f"providers.{filename[:-3]}" module_name = f"providers.{filename[:-3]}"
@ -202,7 +205,6 @@ class Scanner:
attribute = getattr(module, attribute_name) attribute = getattr(module, attribute_name)
if isinstance(attribute, type) and issubclass(attribute, BaseProvider) and attribute is not BaseProvider: if isinstance(attribute, type) and issubclass(attribute, BaseProvider) and attribute is not BaseProvider:
provider_class = attribute provider_class = attribute
# FIXED: Pass the 'name' argument during initialization
provider = provider_class(name=attribute_name, session_config=self.config) provider = provider_class(name=attribute_name, session_config=self.config)
provider_name = provider.get_name() provider_name = provider.get_name()
@ -223,8 +225,13 @@ class Scanner:
if is_available: if is_available:
provider.set_stop_event(self.stop_event) provider.set_stop_event(self.stop_event)
# Special handling for correlation provider
if isinstance(provider, CorrelationProvider): if isinstance(provider, CorrelationProvider):
provider.set_graph_manager(self.graph) provider.set_graph_manager(self.graph)
correlation_provider_instance = provider
print(f" ✓ Correlation provider configured with graph manager")
self.providers.append(provider) self.providers.append(provider)
print(f" ✓ Added to scanner") print(f" ✓ Added to scanner")
else: else:
@ -239,6 +246,11 @@ class Scanner:
print(f"=== PROVIDER INITIALIZATION COMPLETE ===") print(f"=== PROVIDER INITIALIZATION COMPLETE ===")
print(f"Active providers: {[p.get_name() for p in self.providers]}") print(f"Active providers: {[p.get_name() for p in self.providers]}")
print(f"Provider count: {len(self.providers)}") print(f"Provider count: {len(self.providers)}")
# Verify correlation provider is properly configured
if correlation_provider_instance:
print(f"Correlation provider configured: {correlation_provider_instance.graph is not None}")
print("=" * 50) print("=" * 50)
def _status_logger_thread(self): def _status_logger_thread(self):
@ -450,12 +462,10 @@ class Scanner:
def _execute_scan(self, target: str, max_depth: int) -> None: def _execute_scan(self, target: str, max_depth: int) -> None:
self.executor = ThreadPoolExecutor(max_workers=self.max_workers) self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
processed_tasks = set() # FIXED: Now includes depth to avoid incorrect skipping processed_tasks = set()
is_ip = _is_valid_ip(target) is_ip = _is_valid_ip(target)
initial_providers = self._get_eligible_providers(target, is_ip, False) initial_providers = [p for p in self._get_eligible_providers(target, is_ip, False) if not isinstance(p, CorrelationProvider)]
# FIXED: Filter out correlation provider from initial providers
initial_providers = [p for p in initial_providers if not isinstance(p, CorrelationProvider)]
for provider in initial_providers: for provider in initial_providers:
provider_name = provider.get_name() provider_name = provider.get_name()
@ -474,9 +484,8 @@ class Scanner:
self.graph.add_node(target, node_type) self.graph.add_node(target, node_type)
self._initialize_provider_states(target) self._initialize_provider_states(target)
consecutive_empty_iterations = 0 consecutive_empty_iterations = 0
max_empty_iterations = 50 # Allow 5 seconds of empty queue before considering completion max_empty_iterations = 50
# PHASE 1: Run all non-correlation providers
print(f"\n=== PHASE 1: Running non-correlation providers ===") print(f"\n=== PHASE 1: Running non-correlation providers ===")
while not self._is_stop_requested(): while not self._is_stop_requested():
queue_empty = self.task_queue.empty() queue_empty = self.task_queue.empty()
@ -486,57 +495,39 @@ class Scanner:
if queue_empty and no_active_processing: if queue_empty and no_active_processing:
consecutive_empty_iterations += 1 consecutive_empty_iterations += 1
if consecutive_empty_iterations >= max_empty_iterations: if consecutive_empty_iterations >= max_empty_iterations:
break # Phase 1 complete break
time.sleep(0.1) time.sleep(0.1)
continue continue
else: else:
consecutive_empty_iterations = 0 consecutive_empty_iterations = 0
# Process tasks (same logic as before, but correlations are filtered out)
try: try:
run_at, priority, (provider_name, target_item, depth) = self.task_queue.get(timeout=0.1) run_at, priority, (provider_name, target_item, depth) = self.task_queue.get(timeout=0.1)
if provider_name == 'correlation': continue
# Skip correlation tasks during Phase 1
if provider_name == 'correlation':
continue
# Check if task is ready to run
current_time = time.time() current_time = time.time()
if run_at > current_time: if run_at > current_time:
self.task_queue.put((run_at, priority, (provider_name, target_item, depth))) self.task_queue.put((run_at, priority, (provider_name, target_item, depth)))
time.sleep(min(0.5, run_at - current_time)) time.sleep(min(0.5, run_at - current_time))
continue continue
except:
except: # Queue is empty or timeout occurred
time.sleep(0.1) time.sleep(0.1)
continue continue
self.last_task_from_queue = (run_at, priority, (provider_name, target_item, depth)) self.last_task_from_queue = (run_at, priority, (provider_name, target_item, depth))
# Skip if already processed
task_tuple = (provider_name, target_item, depth) task_tuple = (provider_name, target_item, depth)
if task_tuple in processed_tasks: if task_tuple in processed_tasks or depth > max_depth:
self.tasks_skipped += 1 self.tasks_skipped += 1
self.indicators_completed += 1 self.indicators_completed += 1
continue continue
# Skip if depth exceeded
if depth > max_depth:
self.tasks_skipped += 1
self.indicators_completed += 1
continue
# Rate limiting with proper time-based deferral
if self.rate_limiter.is_rate_limited(provider_name, self.config.get_rate_limit(provider_name), 60): if self.rate_limiter.is_rate_limited(provider_name, self.config.get_rate_limit(provider_name), 60):
defer_until = time.time() + 60 defer_until = time.time() + 60
self.task_queue.put((defer_until, priority, (provider_name, target_item, depth))) self.task_queue.put((defer_until, priority, (provider_name, target_item, depth)))
self.tasks_re_enqueued += 1 self.tasks_re_enqueued += 1
continue continue
# Thread-safe processing state management
with self.processing_lock: with self.processing_lock:
if self._is_stop_requested(): if self._is_stop_requested(): break
break
processing_key = (provider_name, target_item) processing_key = (provider_name, target_item)
if processing_key in self.currently_processing: if processing_key in self.currently_processing:
self.tasks_skipped += 1 self.tasks_skipped += 1
@ -548,29 +539,21 @@ class Scanner:
self.current_depth = depth self.current_depth = depth
self.current_indicator = target_item self.current_indicator = target_item
self._update_session_state() self._update_session_state()
if self._is_stop_requested(): break
if self._is_stop_requested():
break
provider = next((p for p in self.providers if p.get_name() == provider_name), None) provider = next((p for p in self.providers if p.get_name() == provider_name), None)
if provider and not isinstance(provider, CorrelationProvider): if provider and not isinstance(provider, CorrelationProvider):
new_targets, _, success = self._process_provider_task(provider, target_item, depth) new_targets, _, success = self._process_provider_task(provider, target_item, depth)
if self._is_stop_requested(): break
if self._is_stop_requested():
break
if not success: if not success:
retry_key = (provider_name, target_item, depth) retry_key = (provider_name, target_item, depth)
self.target_retries[retry_key] += 1 self.target_retries[retry_key] += 1
if self.target_retries[retry_key] <= self.config.max_retries_per_target: if self.target_retries[retry_key] <= self.config.max_retries_per_target:
retry_count = self.target_retries[retry_key] retry_count = self.target_retries[retry_key]
backoff_delay = min(300, (2 ** retry_count) + random.uniform(0, 1)) backoff_delay = min(300, (2 ** retry_count) + random.uniform(0, 1))
retry_at = time.time() + backoff_delay self.task_queue.put((time.time() + backoff_delay, priority, (provider_name, target_item, depth)))
self.task_queue.put((retry_at, priority, (provider_name, target_item, depth)))
self.tasks_re_enqueued += 1 self.tasks_re_enqueued += 1
self.logger.logger.debug(f"Retrying {provider_name}:{target_item} in {backoff_delay:.1f}s (attempt {retry_count})")
else: else:
self.scan_failed_due_to_retries = True self.scan_failed_due_to_retries = True
self._log_target_processing_error(str(task_tuple), f"Max retries ({self.config.max_retries_per_target}) exceeded") self._log_target_processing_error(str(task_tuple), f"Max retries ({self.config.max_retries_per_target}) exceeded")
@ -578,54 +561,34 @@ class Scanner:
processed_tasks.add(task_tuple) processed_tasks.add(task_tuple)
self.indicators_completed += 1 self.indicators_completed += 1
# Enqueue new targets with proper depth tracking
if not self._is_stop_requested(): if not self._is_stop_requested():
for new_target in new_targets: for new_target in new_targets:
is_ip_new = _is_valid_ip(new_target) is_ip_new = _is_valid_ip(new_target)
eligible_providers_new = self._get_eligible_providers(new_target, is_ip_new, False) eligible_providers_new = [p for p in self._get_eligible_providers(new_target, is_ip_new, False) if not isinstance(p, CorrelationProvider)]
# FIXED: Filter out correlation providers in Phase 1
eligible_providers_new = [p for p in eligible_providers_new if not isinstance(p, CorrelationProvider)]
for p_new in eligible_providers_new: for p_new in eligible_providers_new:
p_name_new = p_new.get_name() p_name_new = p_new.get_name()
new_depth = depth + 1 new_depth = depth + 1
new_task_tuple = (p_name_new, new_target, new_depth) if (p_name_new, new_target, new_depth) not in processed_tasks and new_depth <= max_depth:
self.task_queue.put((time.time(), self._get_priority(p_name_new), (p_name_new, new_target, new_depth)))
if new_task_tuple not in processed_tasks and new_depth <= max_depth:
new_priority = self._get_priority(p_name_new)
self.task_queue.put((time.time(), new_priority, (p_name_new, new_target, new_depth)))
self.total_tasks_ever_enqueued += 1 self.total_tasks_ever_enqueued += 1
else: else:
self.logger.logger.warning(f"Provider {provider_name} not found in active providers")
self.tasks_skipped += 1 self.tasks_skipped += 1
self.indicators_completed += 1 self.indicators_completed += 1
finally: finally:
with self.processing_lock: with self.processing_lock:
processing_key = (provider_name, target_item) self.currently_processing.discard((provider_name, target_item))
self.currently_processing.discard(processing_key)
# PHASE 2: Run correlations on all discovered nodes # This code runs after the main loop finishes or is stopped.
if not self._is_stop_requested(): self.status = ScanStatus.FINALIZING
self._update_session_state()
self.logger.logger.info("Scan stopped or completed. Entering finalization phase.")
if self.status in [ScanStatus.FINALIZING, ScanStatus.COMPLETED, ScanStatus.STOPPED]:
print(f"\n=== PHASE 2: Running correlation analysis ===") print(f"\n=== PHASE 2: Running correlation analysis ===")
self._run_correlation_phase(max_depth, processed_tasks) self._run_correlation_phase(max_depth, processed_tasks)
self._update_session_state()
except Exception as e: # Determine the final status *after* finalization.
traceback.print_exc()
self.status = ScanStatus.FAILED
self.logger.logger.error(f"Scan failed: {e}")
finally:
# Comprehensive cleanup (same as before)
with self.processing_lock:
self.currently_processing.clear()
self.currently_processing_display = []
while not self.task_queue.empty():
try:
self.task_queue.get_nowait()
except:
break
if self._is_stop_requested(): if self._is_stop_requested():
self.status = ScanStatus.STOPPED self.status = ScanStatus.STOPPED
elif self.scan_failed_due_to_retries: elif self.scan_failed_due_to_retries:
@ -633,13 +596,25 @@ class Scanner:
else: else:
self.status = ScanStatus.COMPLETED self.status = ScanStatus.COMPLETED
except Exception as e:
traceback.print_exc()
self.status = ScanStatus.FAILED
self.logger.logger.error(f"Scan failed: {e}")
finally:
# The 'finally' block is now only for guaranteed cleanup.
with self.processing_lock:
self.currently_processing.clear()
self.currently_processing_display = []
while not self.task_queue.empty():
try: self.task_queue.get_nowait()
except: break
self.status_logger_stop_event.set() self.status_logger_stop_event.set()
if self.status_logger_thread and self.status_logger_thread.is_alive(): if self.status_logger_thread and self.status_logger_thread.is_alive():
self.status_logger_thread.join(timeout=2.0) self.status_logger_thread.join(timeout=2.0)
self._update_session_state() # The executor shutdown now happens *after* the correlation phase has run.
self.logger.log_scan_complete()
if self.executor: if self.executor:
try: try:
self.executor.shutdown(wait=False, cancel_futures=True) self.executor.shutdown(wait=False, cancel_futures=True)
@ -648,26 +623,31 @@ class Scanner:
finally: finally:
self.executor = None self.executor = None
self._update_session_state()
self.logger.log_scan_complete()
def _run_correlation_phase(self, max_depth: int, processed_tasks: set) -> None: def _run_correlation_phase(self, max_depth: int, processed_tasks: set) -> None:
""" """
PHASE 2: Run correlation analysis on all discovered nodes. PHASE 2: Run correlation analysis on all discovered nodes.
This ensures correlations run after all other providers have completed. Enhanced with better error handling and progress tracking.
""" """
correlation_provider = next((p for p in self.providers if isinstance(p, CorrelationProvider)), None) correlation_provider = next((p for p in self.providers if isinstance(p, CorrelationProvider)), None)
if not correlation_provider: if not correlation_provider:
print("No correlation provider found - skipping correlation phase") print("No correlation provider found - skipping correlation phase")
return return
# Ensure correlation provider has access to current graph state
correlation_provider.set_graph_manager(self.graph)
print(f"Correlation provider configured with graph containing {self.graph.get_node_count()} nodes")
# Get all nodes from the graph for correlation analysis # Get all nodes from the graph for correlation analysis
all_nodes = list(self.graph.graph.nodes()) all_nodes = list(self.graph.graph.nodes())
correlation_tasks = [] correlation_tasks = []
correlation_tasks_enqueued = 0
print(f"Enqueueing correlation tasks for {len(all_nodes)} nodes") print(f"Enqueueing correlation tasks for {len(all_nodes)} nodes")
for node_id in all_nodes: for node_id in all_nodes:
if self._is_stop_requested():
break
# Determine appropriate depth for correlation (use 0 for simplicity) # Determine appropriate depth for correlation (use 0 for simplicity)
correlation_depth = 0 correlation_depth = 0
task_tuple = ('correlation', node_id, correlation_depth) task_tuple = ('correlation', node_id, correlation_depth)
@ -677,15 +657,22 @@ class Scanner:
priority = self._get_priority('correlation') priority = self._get_priority('correlation')
self.task_queue.put((time.time(), priority, ('correlation', node_id, correlation_depth))) self.task_queue.put((time.time(), priority, ('correlation', node_id, correlation_depth)))
correlation_tasks.append(task_tuple) correlation_tasks.append(task_tuple)
correlation_tasks_enqueued += 1
self.total_tasks_ever_enqueued += 1 self.total_tasks_ever_enqueued += 1
print(f"Enqueued {len(correlation_tasks)} correlation tasks") print(f"Enqueued {correlation_tasks_enqueued} new correlation tasks")
# Process correlation tasks # Force session state update to reflect new task count
self._update_session_state()
# Process correlation tasks with enhanced tracking
consecutive_empty_iterations = 0 consecutive_empty_iterations = 0
max_empty_iterations = 20 # Shorter timeout for correlation phase max_empty_iterations = 20
correlation_completed = 0
correlation_errors = 0
while not self._is_stop_requested() and correlation_tasks: while correlation_tasks:
# Check if we should continue processing
queue_empty = self.task_queue.empty() queue_empty = self.task_queue.empty()
with self.processing_lock: with self.processing_lock:
no_active_processing = len(self.currently_processing) == 0 no_active_processing = len(self.currently_processing) == 0
@ -693,6 +680,7 @@ class Scanner:
if queue_empty and no_active_processing: if queue_empty and no_active_processing:
consecutive_empty_iterations += 1 consecutive_empty_iterations += 1
if consecutive_empty_iterations >= max_empty_iterations: if consecutive_empty_iterations >= max_empty_iterations:
print(f"Correlation phase timeout - {len(correlation_tasks)} tasks remaining")
break break
time.sleep(0.1) time.sleep(0.1)
continue continue
@ -721,8 +709,6 @@ class Scanner:
continue continue
with self.processing_lock: with self.processing_lock:
if self._is_stop_requested():
break
processing_key = (provider_name, target_item) processing_key = (provider_name, target_item)
if processing_key in self.currently_processing: if processing_key in self.currently_processing:
self.tasks_skipped += 1 self.tasks_skipped += 1
@ -734,36 +720,52 @@ class Scanner:
self.current_indicator = target_item self.current_indicator = target_item
self._update_session_state() self._update_session_state()
if self._is_stop_requested(): # Process correlation task with enhanced error handling
break try:
new_targets, _, success = self._process_provider_task(correlation_provider, target_item, depth)
# Process correlation task if success:
new_targets, _, success = self._process_provider_task(correlation_provider, target_item, depth) processed_tasks.add(task_tuple)
correlation_completed += 1
self.indicators_completed += 1
if task_tuple in correlation_tasks:
correlation_tasks.remove(task_tuple)
else:
# For correlations, don't retry - just mark as completed
correlation_errors += 1
self.indicators_completed += 1
if task_tuple in correlation_tasks:
correlation_tasks.remove(task_tuple)
if success: except Exception as e:
processed_tasks.add(task_tuple) correlation_errors += 1
self.indicators_completed += 1
if task_tuple in correlation_tasks:
correlation_tasks.remove(task_tuple)
else:
# For correlations, don't retry - just mark as completed
self.indicators_completed += 1 self.indicators_completed += 1
if task_tuple in correlation_tasks: if task_tuple in correlation_tasks:
correlation_tasks.remove(task_tuple) correlation_tasks.remove(task_tuple)
self.logger.logger.warning(f"Correlation task failed for {target_item}: {e}")
finally: finally:
with self.processing_lock: with self.processing_lock:
processing_key = (provider_name, target_item) processing_key = (provider_name, target_item)
self.currently_processing.discard(processing_key) self.currently_processing.discard(processing_key)
print(f"Correlation phase complete. Remaining tasks: {len(correlation_tasks)}") # Periodic progress update during correlation phase
if correlation_completed % 10 == 0 and correlation_completed > 0:
remaining = len(correlation_tasks)
print(f"Correlation progress: {correlation_completed} completed, {remaining} remaining")
print(f"Correlation phase complete:")
print(f" - Successfully processed: {correlation_completed}")
print(f" - Errors encountered: {correlation_errors}")
print(f" - Tasks remaining: {len(correlation_tasks)}")
def _process_provider_task(self, provider: BaseProvider, target: str, depth: int) -> Tuple[Set[str], Set[str], bool]: def _process_provider_task(self, provider: BaseProvider, target: str, depth: int) -> Tuple[Set[str], Set[str], bool]:
""" """
Manages the entire process for a given target and provider. Manages the entire process for a given target and provider.
This version is generalized to handle all relationships dynamically. This version is generalized to handle all relationships dynamically.
""" """
if self._is_stop_requested(): if self._is_stop_requested() and not isinstance(provider, CorrelationProvider):
return set(), set(), False return set(), set(), False
is_ip = _is_valid_ip(target) is_ip = _is_valid_ip(target)
@ -780,7 +782,8 @@ class Scanner:
if provider_result is None: if provider_result is None:
provider_successful = False provider_successful = False
elif not self._is_stop_requested(): # Allow correlation provider to process results even if scan is stopped
elif not self._is_stop_requested() or isinstance(provider, CorrelationProvider):
# Pass all relationships to be processed # Pass all relationships to be processed
discovered, is_large_entity = self._process_provider_result_unified( discovered, is_large_entity = self._process_provider_result_unified(
target, provider, provider_result, depth target, provider, provider_result, depth
@ -800,7 +803,7 @@ class Scanner:
provider_name = provider.get_name() provider_name = provider.get_name()
start_time = datetime.now(timezone.utc) start_time = datetime.now(timezone.utc)
if self._is_stop_requested(): if self._is_stop_requested() and not isinstance(provider, CorrelationProvider):
return None return None
try: try:
@ -809,7 +812,7 @@ class Scanner:
else: else:
result = provider.query_domain(target) result = provider.query_domain(target)
if self._is_stop_requested(): if self._is_stop_requested() and not isinstance(provider, CorrelationProvider):
return None return None
relationship_count = result.get_relationship_count() if result else 0 relationship_count = result.get_relationship_count() if result else 0
@ -822,18 +825,33 @@ class Scanner:
return None return None
def _create_large_entity_from_result(self, source_node: str, provider_name: str, def _create_large_entity_from_result(self, source_node: str, provider_name: str,
provider_result: ProviderResult, depth: int) -> Tuple[str, Set[str]]: provider_result: ProviderResult, depth: int) -> Tuple[str, Set[str]]:
""" """
Creates a large entity node, tags all member nodes, and returns its ID and members. Creates a large entity node, tags all member nodes, and stores original relationships.
FIXED: Now stores original relationships for later restoration during extraction.
""" """
members = {rel.target_node for rel in provider_result.relationships members = {rel.target_node for rel in provider_result.relationships
if _is_valid_domain(rel.target_node) or _is_valid_ip(rel.target_node)} if _is_valid_domain(rel.target_node) or _is_valid_ip(rel.target_node)}
if not members: if not members:
return "", set() return "", set()
large_entity_id = f"le_{provider_name}_{source_node}" large_entity_id = f"le_{provider_name}_{source_node}"
# FIXED: Store original relationships for each member
member_relationships = {}
for rel in provider_result.relationships:
if rel.target_node in members:
if rel.target_node not in member_relationships:
member_relationships[rel.target_node] = []
member_relationships[rel.target_node].append({
'source_node': rel.source_node,
'target_node': rel.target_node,
'relationship_type': rel.relationship_type,
'provider': rel.provider,
'raw_data': rel.raw_data
})
self.graph.add_node( self.graph.add_node(
node_id=large_entity_id, node_id=large_entity_id,
node_type=NodeType.LARGE_ENTITY, node_type=NodeType.LARGE_ENTITY,
@ -841,7 +859,8 @@ class Scanner:
{"name": "count", "value": len(members), "type": "statistic"}, {"name": "count", "value": len(members), "type": "statistic"},
{"name": "source_provider", "value": provider_name, "type": "metadata"}, {"name": "source_provider", "value": provider_name, "type": "metadata"},
{"name": "discovery_depth", "value": depth, "type": "metadata"}, {"name": "discovery_depth", "value": depth, "type": "metadata"},
{"name": "nodes", "value": list(members), "type": "metadata"} {"name": "nodes", "value": list(members), "type": "metadata"},
{"name": "original_relationships", "value": member_relationships, "type": "metadata"} # FIXED: Store original relationships
], ],
description=f"A collection of {len(members)} nodes discovered from {source_node} via {provider_name}." description=f"A collection of {len(members)} nodes discovered from {source_node} via {provider_name}."
) )
@ -858,7 +877,8 @@ class Scanner:
def extract_node_from_large_entity(self, large_entity_id: str, node_id: str) -> bool: def extract_node_from_large_entity(self, large_entity_id: str, node_id: str) -> bool:
""" """
Removes a node from a large entity, allowing it to be processed normally. Removes a node from a large entity and restores its original relationships.
FIXED: Now restores original relationships to make the node reachable.
""" """
if not self.graph.graph.has_node(node_id): if not self.graph.graph.has_node(node_id):
return False return False
@ -866,31 +886,66 @@ class Scanner:
node_data = self.graph.graph.nodes[node_id] node_data = self.graph.graph.nodes[node_id]
metadata = node_data.get('metadata', {}) metadata = node_data.get('metadata', {})
if metadata.get('large_entity_id') == large_entity_id: if metadata.get('large_entity_id') != large_entity_id:
# Remove the large entity tag return False
del metadata['large_entity_id']
self.graph.add_node(node_id, NodeType(node_data['type']), metadata=metadata)
# Re-enqueue the node for full processing # Remove the large entity tag
is_ip = _is_valid_ip(node_id) del metadata['large_entity_id']
eligible_providers = self._get_eligible_providers(node_id, is_ip, False) self.graph.add_node(node_id, NodeType(node_data['type']), metadata=metadata)
for provider in eligible_providers:
provider_name = provider.get_name()
priority = self._get_priority(provider_name)
# Use current depth of the large entity if available, else 0
depth = 0
if self.graph.graph.has_node(large_entity_id):
le_attrs = self.graph.graph.nodes[large_entity_id].get('attributes', [])
depth_attr = next((a for a in le_attrs if a['name'] == 'discovery_depth'), None)
if depth_attr:
depth = depth_attr['value']
self.task_queue.put((time.time(), priority, (provider_name, node_id, depth))) # FIXED: Restore original relationships if they exist
self.total_tasks_ever_enqueued += 1 if self.graph.graph.has_node(large_entity_id):
le_attrs = self.graph.graph.nodes[large_entity_id].get('attributes', [])
original_relationships_attr = next((a for a in le_attrs if a['name'] == 'original_relationships'), None)
return True if original_relationships_attr and node_id in original_relationships_attr['value']:
# Restore all original relationships for this node
for rel_data in original_relationships_attr['value'][node_id]:
self.graph.add_edge(
source_id=rel_data['source_node'],
target_id=rel_data['target_node'],
relationship_type=rel_data['relationship_type'],
source_provider=rel_data['provider'],
raw_data=rel_data['raw_data']
)
return False # Ensure both nodes exist in the graph
source_type = NodeType.IP if _is_valid_ip(rel_data['source_node']) else NodeType.DOMAIN
target_type = NodeType.IP if _is_valid_ip(rel_data['target_node']) else NodeType.DOMAIN
self.graph.add_node(rel_data['source_node'], source_type)
self.graph.add_node(rel_data['target_node'], target_type)
# Update the large entity to remove this node from its list
nodes_attr = next((a for a in le_attrs if a['name'] == 'nodes'), None)
if nodes_attr and node_id in nodes_attr['value']:
nodes_attr['value'].remove(node_id)
count_attr = next((a for a in le_attrs if a['name'] == 'count'), None)
if count_attr:
count_attr['value'] = max(0, count_attr['value'] - 1)
# Remove from original relationships tracking
if node_id in original_relationships_attr['value']:
del original_relationships_attr['value'][node_id]
# Re-enqueue the node for full processing
is_ip = _is_valid_ip(node_id)
eligible_providers = self._get_eligible_providers(node_id, is_ip, False, is_extracted=True)
for provider in eligible_providers:
provider_name = provider.get_name()
priority = self._get_priority(provider_name)
# Use current depth of the large entity if available, else 0
depth = 0
if self.graph.graph.has_node(large_entity_id):
le_attrs = self.graph.graph.nodes[large_entity_id].get('attributes', [])
depth_attr = next((a for a in le_attrs if a['name'] == 'discovery_depth'), None)
if depth_attr:
depth = depth_attr['value']
self.task_queue.put((time.time(), priority, (provider_name, node_id, depth)))
self.total_tasks_ever_enqueued += 1
return True
def _process_provider_result_unified(self, target: str, provider: BaseProvider, def _process_provider_result_unified(self, target: str, provider: BaseProvider,
provider_result: ProviderResult, current_depth: int) -> Tuple[Set[str], bool]: provider_result: ProviderResult, current_depth: int) -> Tuple[Set[str], bool]:
@ -903,7 +958,8 @@ class Scanner:
large_entity_id = "" large_entity_id = ""
large_entity_members = set() large_entity_members = set()
if self._is_stop_requested(): # Stop processing for non-correlation providers if requested
if self._is_stop_requested() and not isinstance(provider, CorrelationProvider):
return discovered_targets, False return discovered_targets, False
eligible_rel_count = sum( eligible_rel_count = sum(
@ -917,7 +973,8 @@ class Scanner:
) )
for i, relationship in enumerate(provider_result.relationships): for i, relationship in enumerate(provider_result.relationships):
if i % 5 == 0 and self._is_stop_requested(): # Stop processing for non-correlation providers if requested
if i % 5 == 0 and self._is_stop_requested() and not isinstance(provider, CorrelationProvider):
break break
source_node_id = relationship.source_node source_node_id = relationship.source_node
@ -954,7 +1011,6 @@ class Scanner:
self.graph.add_edge( self.graph.add_edge(
visual_source, visual_target, visual_source, visual_target,
relationship.relationship_type, relationship.relationship_type,
relationship.confidence,
provider_name, provider_name,
relationship.raw_data relationship.raw_data
) )
@ -977,7 +1033,7 @@ class Scanner:
for attribute in provider_result.attributes: for attribute in provider_result.attributes:
attr_dict = { attr_dict = {
"name": attribute.name, "value": attribute.value, "type": attribute.type, "name": attribute.name, "value": attribute.value, "type": attribute.type,
"provider": attribute.provider, "confidence": attribute.confidence, "metadata": attribute.metadata "provider": attribute.provider, "metadata": attribute.metadata
} }
attributes_by_node[attribute.target_node].append(attr_dict) attributes_by_node[attribute.target_node].append(attr_dict)
@ -1078,7 +1134,7 @@ class Scanner:
self.logger.logger.warning(f"Error initializing provider states for {target}: {e}") self.logger.logger.warning(f"Error initializing provider states for {target}: {e}")
def _get_eligible_providers(self, target: str, is_ip: bool, dns_only: bool) -> List: def _get_eligible_providers(self, target: str, is_ip: bool, dns_only: bool, is_extracted: bool = False) -> List:
""" """
FIXED: Improved provider eligibility checking with better filtering. FIXED: Improved provider eligibility checking with better filtering.
""" """
@ -1090,7 +1146,7 @@ class Scanner:
# Check if the target is part of a large entity # Check if the target is part of a large entity
is_in_large_entity = False is_in_large_entity = False
if self.graph.graph.has_node(target): if self.graph.graph.has_node(target) and not is_extracted:
metadata = self.graph.graph.nodes[target].get('metadata', {}) metadata = self.graph.graph.nodes[target].get('metadata', {})
if 'large_entity_id' in metadata: if 'large_entity_id' in metadata:
is_in_large_entity = True is_in_large_entity = True
@ -1182,6 +1238,10 @@ class Scanner:
self.logger.logger.error(f"Provider {provider_name} failed for {target}: {error}") self.logger.logger.error(f"Provider {provider_name} failed for {target}: {error}")
def _calculate_progress(self) -> float: def _calculate_progress(self) -> float:
"""
Enhanced progress calculation that properly accounts for correlation tasks
added during the correlation phase.
"""
try: try:
if self.total_tasks_ever_enqueued == 0: if self.total_tasks_ever_enqueued == 0:
return 0.0 return 0.0
@ -1191,7 +1251,18 @@ class Scanner:
with self.processing_lock: with self.processing_lock:
active_tasks = len(self.currently_processing) active_tasks = len(self.currently_processing)
# Adjust total to account for remaining work # For correlation phase, be more conservative about progress calculation
if self.status == ScanStatus.FINALIZING:
# During correlation phase, show progress more conservatively
base_progress = (self.indicators_completed / max(self.total_tasks_ever_enqueued, 1)) * 100
# If we have active correlation tasks, cap progress at 95% until done
if queue_size > 0 or active_tasks > 0:
return min(95.0, base_progress)
else:
return min(100.0, base_progress)
# Normal phase progress calculation
adjusted_total = max(self.total_tasks_ever_enqueued, adjusted_total = max(self.total_tasks_ever_enqueued,
self.indicators_completed + queue_size + active_tasks) self.indicators_completed + queue_size + active_tasks)

View File

@ -1,5 +1,5 @@
""" """
Per-session configuration management for DNSRecon. Per-session configuration management for DNScope.
Provides isolated configuration instances for each user session. Provides isolated configuration instances for each user session.
""" """

View File

@ -1,4 +1,4 @@
# dnsrecon/core/session_manager.py # DNScope/core/session_manager.py
import threading import threading
import time import time
@ -58,11 +58,11 @@ class SessionManager:
def _get_session_key(self, session_id: str) -> str: def _get_session_key(self, session_id: str) -> str:
"""Generates the Redis key for a session.""" """Generates the Redis key for a session."""
return f"dnsrecon:session:{session_id}" return f"DNScope:session:{session_id}"
def _get_stop_signal_key(self, session_id: str) -> str: def _get_stop_signal_key(self, session_id: str) -> str:
"""Generates the Redis key for a session's stop signal.""" """Generates the Redis key for a session's stop signal."""
return f"dnsrecon:stop:{session_id}" return f"DNScope:stop:{session_id}"
def create_session(self) -> str: def create_session(self) -> str:
""" """
@ -353,7 +353,7 @@ class SessionManager:
while True: while True:
try: try:
# Clean up orphaned stop signals # Clean up orphaned stop signals
stop_keys = self.redis_client.keys("dnsrecon:stop:*") stop_keys = self.redis_client.keys("DNScope:stop:*")
for stop_key in stop_keys: for stop_key in stop_keys:
# Extract session ID from stop key # Extract session ID from stop key
session_id = stop_key.decode('utf-8').split(':')[-1] session_id = stop_key.decode('utf-8').split(':')[-1]
@ -372,8 +372,8 @@ class SessionManager:
def get_statistics(self) -> Dict[str, Any]: def get_statistics(self) -> Dict[str, Any]:
"""Get session manager statistics.""" """Get session manager statistics."""
try: try:
session_keys = self.redis_client.keys("dnsrecon:session:*") session_keys = self.redis_client.keys("DNScope:session:*")
stop_keys = self.redis_client.keys("dnsrecon:stop:*") stop_keys = self.redis_client.keys("DNScope:stop:*")
active_sessions = len(session_keys) active_sessions = len(session_keys)
running_scans = 0 running_scans = 0

View File

@ -1,5 +1,5 @@
""" """
Data provider modules for DNSRecon. Data provider modules for DNScope.
Contains implementations for various reconnaissance data sources. Contains implementations for various reconnaissance data sources.
""" """

View File

@ -1,4 +1,4 @@
# dnsrecon/providers/base_provider.py # DNScope/providers/base_provider.py
import time import time
import requests import requests
@ -6,14 +6,14 @@ import threading
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from core.logger import get_forensic_logger from core.logger import get_forensic_logger # Ensure this import is present
from core.rate_limiter import GlobalRateLimiter from core.rate_limiter import GlobalRateLimiter
from core.provider_result import ProviderResult from core.provider_result import ProviderResult
class BaseProvider(ABC): class BaseProvider(ABC):
""" """
Abstract base class for all DNSRecon data providers. Abstract base class for all DNScope data providers.
Now supports session-specific configuration and returns standardized ProviderResult objects. Now supports session-specific configuration and returns standardized ProviderResult objects.
""" """
@ -41,7 +41,6 @@ class BaseProvider(ABC):
self.name = name self.name = name
self.timeout = actual_timeout self.timeout = actual_timeout
self._local = threading.local() self._local = threading.local()
self.logger = get_forensic_logger()
self._stop_event = None self._stop_event = None
# Statistics (per provider instance) # Statistics (per provider instance)
@ -72,10 +71,15 @@ class BaseProvider(ABC):
if not hasattr(self._local, 'session'): if not hasattr(self._local, 'session'):
self._local.session = requests.Session() self._local.session = requests.Session()
self._local.session.headers.update({ self._local.session.headers.update({
'User-Agent': 'DNSRecon/1.0 (Passive Reconnaissance Tool)' 'User-Agent': 'DNScope/1.0 (Passive Reconnaissance Tool)'
}) })
return self._local.session return self._local.session
@property
def logger(self):
"""Get the current forensic logger instance."""
return get_forensic_logger()
@abstractmethod @abstractmethod
def get_name(self) -> str: def get_name(self) -> str:
"""Return the provider name.""" """Return the provider name."""
@ -229,7 +233,6 @@ class BaseProvider(ABC):
def log_relationship_discovery(self, source_node: str, target_node: str, def log_relationship_discovery(self, source_node: str, target_node: str,
relationship_type: str, relationship_type: str,
confidence_score: float,
raw_data: Dict[str, Any], raw_data: Dict[str, Any],
discovery_method: str) -> None: discovery_method: str) -> None:
""" """
@ -239,7 +242,6 @@ class BaseProvider(ABC):
source_node: Source node identifier source_node: Source node identifier
target_node: Target node identifier target_node: Target node identifier
relationship_type: Type of relationship relationship_type: Type of relationship
confidence_score: Confidence score
raw_data: Raw data from provider raw_data: Raw data from provider
discovery_method: Method used for discovery discovery_method: Method used for discovery
""" """
@ -249,7 +251,6 @@ class BaseProvider(ABC):
source_node=source_node, source_node=source_node,
target_node=target_node, target_node=target_node,
relationship_type=relationship_type, relationship_type=relationship_type,
confidence_score=confidence_score,
provider=self.name, provider=self.name,
raw_data=raw_data, raw_data=raw_data,
discovery_method=discovery_method discovery_method=discovery_method

View File

@ -1,7 +1,8 @@
# dnsrecon/providers/correlation_provider.py # dnsrecon-reduced/providers/correlation_provider.py
import re import re
from typing import Dict, Any, List from typing import Dict, Any, List
from datetime import datetime, timezone
from .base_provider import BaseProvider from .base_provider import BaseProvider
from core.provider_result import ProviderResult from core.provider_result import ProviderResult
@ -10,6 +11,7 @@ from core.graph_manager import NodeType, GraphManager
class CorrelationProvider(BaseProvider): class CorrelationProvider(BaseProvider):
""" """
A provider that finds correlations between nodes in the graph. A provider that finds correlations between nodes in the graph.
UPDATED: Enhanced with discovery timestamps for time-based edge coloring.
""" """
def __init__(self, name: str = "correlation", session_config=None): def __init__(self, name: str = "correlation", session_config=None):
@ -22,11 +24,16 @@ class CorrelationProvider(BaseProvider):
self.date_pattern = re.compile(r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}') self.date_pattern = re.compile(r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}')
self.EXCLUDED_KEYS = [ self.EXCLUDED_KEYS = [
'cert_source', 'cert_source',
'a_records',
'mx_records',
'ns_records',
'ptr_records',
'cert_issuer_ca_id', 'cert_issuer_ca_id',
'cert_common_name', 'cert_common_name',
'cert_validity_period_days', 'cert_validity_period_days',
'cert_issuer_name', 'cert_issuer_name',
'cert_entry_timestamp', 'cert_entry_timestamp',
'cert_serial_number', # useless
'cert_not_before', 'cert_not_before',
'cert_not_after', 'cert_not_after',
'dns_ttl', 'dns_ttl',
@ -35,6 +42,8 @@ class CorrelationProvider(BaseProvider):
'updated_timestamp', 'updated_timestamp',
'discovery_timestamp', 'discovery_timestamp',
'query_timestamp', 'query_timestamp',
'shodan_ip_str',
'shodan_a_record',
] ]
def get_name(self) -> str: def get_name(self) -> str:
@ -60,12 +69,14 @@ class CorrelationProvider(BaseProvider):
def query_domain(self, domain: str) -> ProviderResult: def query_domain(self, domain: str) -> ProviderResult:
""" """
Query the provider for information about a domain. Query the provider for information about a domain.
UPDATED: Enhanced with discovery timestamps for time-based edge coloring.
""" """
return self._find_correlations(domain) return self._find_correlations(domain)
def query_ip(self, ip: str) -> ProviderResult: def query_ip(self, ip: str) -> ProviderResult:
""" """
Query the provider for information about an IP address. Query the provider for information about an IP address.
UPDATED: Enhanced with discovery timestamps for time-based edge coloring.
""" """
return self._find_correlations(ip) return self._find_correlations(ip)
@ -77,102 +88,196 @@ class CorrelationProvider(BaseProvider):
def _find_correlations(self, node_id: str) -> ProviderResult: def _find_correlations(self, node_id: str) -> ProviderResult:
""" """
Find correlations for a given node. Find correlations for a given node with enhanced filtering and error handling.
UPDATED: Enhanced with discovery timestamps for time-based edge coloring and list value processing.
""" """
result = ProviderResult() result = ProviderResult()
# FIXED: Ensure self.graph is not None before proceeding. discovery_time = datetime.now(timezone.utc)
# Enhanced safety checks
if not self.graph or not self.graph.graph.has_node(node_id): if not self.graph or not self.graph.graph.has_node(node_id):
return result return result
node_attributes = self.graph.graph.nodes[node_id].get('attributes', []) try:
node_attributes = self.graph.graph.nodes[node_id].get('attributes', [])
for attr in node_attributes: # Ensure attributes is a list (handle legacy data)
attr_name = attr.get('name') if not isinstance(node_attributes, list):
attr_value = attr.get('value') return result
attr_provider = attr.get('provider', 'unknown')
should_exclude = ( correlations_found = 0
any(excluded_key in attr_name or attr_name == excluded_key for excluded_key in self.EXCLUDED_KEYS) or
not isinstance(attr_value, (str, int, float, bool)) or
attr_value is None or
isinstance(attr_value, bool) or
(isinstance(attr_value, str) and (
len(attr_value) < 4 or
self.date_pattern.match(attr_value) or
attr_value.lower() in ['unknown', 'none', 'null', 'n/a', 'true', 'false', '0', '1']
)) or
(isinstance(attr_value, (int, float)) and (
attr_value == 0 or
attr_value == 1 or
abs(attr_value) > 1000000
))
)
if should_exclude: for attr in node_attributes:
continue if not isinstance(attr, dict):
continue
if attr_value not in self.correlation_index: attr_name = attr.get('name', '')
self.correlation_index[attr_value] = { attr_value = attr.get('value')
'nodes': set(), attr_provider = attr.get('provider', 'unknown')
'sources': []
}
self.correlation_index[attr_value]['nodes'].add(node_id) # Prepare a list of values to iterate over
values_to_process = []
if isinstance(attr_value, list):
values_to_process.extend(attr_value)
else:
values_to_process.append(attr_value)
source_info = { for value_item in values_to_process:
'node_id': node_id, # Enhanced filtering logic
'provider': attr_provider, should_exclude = self._should_exclude_attribute(attr_name, value_item)
'attribute': attr_name,
'path': f"{attr_provider}_{attr_name}"
}
existing_sources = [s for s in self.correlation_index[attr_value]['sources'] if should_exclude:
if s['node_id'] == node_id and s['path'] == source_info['path']] continue
if not existing_sources:
self.correlation_index[attr_value]['sources'].append(source_info) # Build correlation index
if value_item not in self.correlation_index:
self.correlation_index[value_item] = {
'nodes': set(),
'sources': []
}
self.correlation_index[value_item]['nodes'].add(node_id)
source_info = {
'node_id': node_id,
'provider': attr_provider,
'attribute': attr_name,
'path': f"{attr_provider}_{attr_name}"
}
# Avoid duplicate sources
existing_sources = [s for s in self.correlation_index[value_item]['sources']
if s['node_id'] == node_id and s['path'] == source_info['path']]
if not existing_sources:
self.correlation_index[value_item]['sources'].append(source_info)
# Create correlation if we have multiple nodes with this value
if len(self.correlation_index[value_item]['nodes']) > 1:
self._create_correlation_relationships(value_item, self.correlation_index[value_item], result, discovery_time)
correlations_found += 1
# Log correlation results
if correlations_found > 0:
self.logger.logger.info(f"Found {correlations_found} correlations for node {node_id}")
except Exception as e:
self.logger.logger.error(f"Error finding correlations for {node_id}: {e}")
if len(self.correlation_index[attr_value]['nodes']) > 1:
self._create_correlation_relationships(attr_value, self.correlation_index[attr_value], result)
return result return result
def _create_correlation_relationships(self, value: Any, correlation_data: Dict[str, Any], result: ProviderResult): def _should_exclude_attribute(self, attr_name: str, attr_value: Any) -> bool:
""" """
Create correlation relationships and add them to the provider result. Enhanced logic to determine if an attribute should be excluded from correlation.
"""
# Check against excluded keys (exact match or substring)
if any(excluded_key in attr_name or attr_name == excluded_key for excluded_key in self.EXCLUDED_KEYS):
return True
# Value type filtering
if not isinstance(attr_value, (str, int, float, bool)) or attr_value is None:
return True
# Boolean values are not useful for correlation
if isinstance(attr_value, bool):
return True
# String value filtering
if isinstance(attr_value, str):
# Date/timestamp strings
if self.date_pattern.match(attr_value):
return True
# Common non-useful values
if attr_value.lower() in ['unknown', 'none', 'null', 'n/a', 'true', 'false', '0', '1']:
return True
# Very long strings that are likely unique (> 100 chars)
if len(attr_value) > 100:
return True
# Numeric value filtering
if isinstance(attr_value, (int, float)):
# Very common values
if attr_value in [0, 1]:
return True
# Very large numbers (likely timestamps or unique IDs)
if abs(attr_value) > 1000000:
return True
return False
def _create_correlation_relationships(self, value: Any, correlation_data: Dict[str, Any],
result: ProviderResult, discovery_time: datetime):
"""
Create correlation relationships with enhanced deduplication and validation.
UPDATED: Enhanced with discovery timestamps for time-based edge coloring.
""" """
correlation_node_id = f"corr_{hash(str(value)) & 0x7FFFFFFF}" correlation_node_id = f"corr_{hash(str(value)) & 0x7FFFFFFF}"
nodes = correlation_data['nodes'] nodes = correlation_data['nodes']
sources = correlation_data['sources'] sources = correlation_data['sources']
# Only create correlations if we have meaningful nodes (more than 1)
if len(nodes) < 2:
return
# Limit correlation size to prevent overly large correlation objects
MAX_CORRELATION_SIZE = 50
if len(nodes) > MAX_CORRELATION_SIZE:
# Sample the nodes to keep correlation manageable
import random
sampled_nodes = random.sample(list(nodes), MAX_CORRELATION_SIZE)
nodes = set(sampled_nodes)
# Filter sources to match sampled nodes
sources = [s for s in sources if s['node_id'] in nodes]
# Add the correlation node as an attribute to the result # Add the correlation node as an attribute to the result
result.add_attribute( result.add_attribute(
target_node=correlation_node_id, target_node=correlation_node_id,
name="correlation_value", name="correlation_value",
value=value, value=value,
attr_type=str(type(value)), attr_type=str(type(value).__name__),
provider=self.name, provider=self.name,
confidence=0.9,
metadata={ metadata={
'correlated_nodes': list(nodes), 'correlated_nodes': list(nodes),
'sources': sources, 'sources': sources,
'correlation_size': len(nodes),
'value_type': type(value).__name__
} }
) )
# Create relationships with source validation and enhanced timestamps
created_relationships = set()
for source in sources: for source in sources:
node_id = source['node_id'] node_id = source['node_id']
provider = source['provider'] provider = source['provider']
attribute = source['attribute'] attribute = source['attribute']
# Skip if we've already created this relationship
relationship_key = (node_id, correlation_node_id)
if relationship_key in created_relationships:
continue
relationship_label = f"corr_{provider}_{attribute}" relationship_label = f"corr_{provider}_{attribute}"
# Enhanced raw_data with discovery timestamp for time-based edge coloring
raw_data = {
'correlation_value': value,
'original_attribute': attribute,
'correlation_type': 'attribute_matching',
'correlation_size': len(nodes),
'discovery_timestamp': discovery_time.isoformat(),
'relevance_timestamp': discovery_time.isoformat() # Correlation data is "fresh" when discovered
}
# Add the relationship to the result # Add the relationship to the result
result.add_relationship( result.add_relationship(
source_node=node_id, source_node=node_id,
target_node=correlation_node_id, target_node=correlation_node_id,
relationship_type=relationship_label, relationship_type=relationship_label,
provider=self.name, provider=self.name,
confidence=0.9, raw_data=raw_data
raw_data={
'correlation_value': value,
'original_attribute': attribute,
'correlation_type': 'attribute_matching'
}
) )
created_relationships.add(relationship_key)

View File

@ -1,45 +1,24 @@
# dnsrecon/providers/crtsh_provider.py # DNScope/providers/crtsh_provider.py
import json import json
import re import re
import psycopg2
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any, Set, Optional from typing import List, Dict, Any, Set, Optional
from urllib.parse import quote from urllib.parse import quote
from datetime import datetime, timezone from datetime import datetime, timezone
import requests import requests
from psycopg2 import pool
from .base_provider import BaseProvider from .base_provider import BaseProvider
from core.provider_result import ProviderResult from core.provider_result import ProviderResult
from utils.helpers import _is_valid_domain from utils.helpers import _is_valid_domain
from core.logger import get_forensic_logger from core.logger import get_forensic_logger
# --- Global Instance for PostgreSQL Connection Pool ---
# This pool will be created once per worker process and is not part of the
# CrtShProvider instance, thus avoiding pickling errors.
db_pool = None
try:
db_pool = psycopg2.pool.SimpleConnectionPool(
1, 5,
host='crt.sh',
port=5432,
user='guest',
dbname='certwatch',
sslmode='prefer',
connect_timeout=60
)
# Use a generic logger here as this is at the module level
get_forensic_logger().logger.info("crt.sh: Global PostgreSQL connection pool created successfully.")
except Exception as e:
get_forensic_logger().logger.warning(f"crt.sh: Failed to create global DB connection pool: {e}. Will fall back to HTTP API.")
class CrtShProvider(BaseProvider): class CrtShProvider(BaseProvider):
""" """
Provider for querying crt.sh certificate transparency database. Provider for querying crt.sh certificate transparency database.
FIXED: Now properly creates domain and CA nodes instead of large entities. FIXED: Improved caching logic and error handling to prevent infinite retry loops.
Returns standardized ProviderResult objects with caching support. Returns standardized ProviderResult objects with caching support.
UPDATED: Enhanced with certificate timestamps for time-based edge coloring.
""" """
def __init__(self, name=None, session_config=None): def __init__(self, name=None, session_config=None):
@ -53,7 +32,7 @@ class CrtShProvider(BaseProvider):
self.base_url = "https://crt.sh/" self.base_url = "https://crt.sh/"
self._stop_event = None self._stop_event = None
# Initialize cache directory (separate from BaseProvider's HTTP cache) # Initialize cache directory
self.domain_cache_dir = Path('cache') / 'crtsh' self.domain_cache_dir = Path('cache') / 'crtsh'
self.domain_cache_dir.mkdir(parents=True, exist_ok=True) self.domain_cache_dir.mkdir(parents=True, exist_ok=True)
@ -87,43 +66,73 @@ class CrtShProvider(BaseProvider):
def _get_cache_status(self, cache_file_path: Path) -> str: def _get_cache_status(self, cache_file_path: Path) -> str:
""" """
Check cache status for a domain. FIXED: More robust cache status checking with better error handling.
Returns: 'not_found', 'fresh', or 'stale' Returns: 'not_found', 'fresh', or 'stale'
""" """
if not cache_file_path.exists(): if not cache_file_path.exists():
return "not_found" return "not_found"
try: try:
with open(cache_file_path, 'r') as f: # Check if file is readable and not corrupted
cache_data = json.load(f) if cache_file_path.stat().st_size == 0:
self.logger.logger.warning(f"Empty cache file: {cache_file_path}")
last_query_str = cache_data.get("last_upstream_query")
if not last_query_str:
return "stale" return "stale"
last_query = datetime.fromisoformat(last_query_str.replace('Z', '+00:00')) with open(cache_file_path, 'r', encoding='utf-8') as f:
hours_since_query = (datetime.now(timezone.utc) - last_query).total_seconds() / 3600 cache_data = json.load(f)
# Validate cache structure
if not isinstance(cache_data, dict):
self.logger.logger.warning(f"Invalid cache structure: {cache_file_path}")
return "stale"
last_query_str = cache_data.get("last_upstream_query")
if not last_query_str or not isinstance(last_query_str, str):
self.logger.logger.warning(f"Missing or invalid last_upstream_query: {cache_file_path}")
return "stale"
try:
# More robust datetime parsing
if last_query_str.endswith('Z'):
last_query = datetime.fromisoformat(last_query_str.replace('Z', '+00:00'))
elif '+' in last_query_str or last_query_str.endswith('UTC'):
# Handle various timezone formats
clean_time = last_query_str.replace('UTC', '').strip()
if '+' in clean_time:
clean_time = clean_time.split('+')[0]
last_query = datetime.fromisoformat(clean_time).replace(tzinfo=timezone.utc)
else:
last_query = datetime.fromisoformat(last_query_str).replace(tzinfo=timezone.utc)
except (ValueError, AttributeError) as e:
self.logger.logger.warning(f"Failed to parse timestamp in cache {cache_file_path}: {e}")
return "stale"
hours_since_query = (datetime.now(timezone.utc) - last_query).total_seconds() / 3600
cache_timeout = self.config.cache_timeout_hours cache_timeout = self.config.cache_timeout_hours
if hours_since_query < cache_timeout: if hours_since_query < cache_timeout:
return "fresh" return "fresh"
else: else:
return "stale" return "stale"
except (json.JSONDecodeError, ValueError, KeyError) as e: except (json.JSONDecodeError, OSError, PermissionError) as e:
self.logger.logger.warning(f"Invalid cache file format for {cache_file_path}: {e}") self.logger.logger.warning(f"Cache file error for {cache_file_path}: {e}")
# FIXED: Try to remove corrupted cache file
try:
cache_file_path.unlink()
self.logger.logger.info(f"Removed corrupted cache file: {cache_file_path}")
except Exception:
pass
return "not_found"
except Exception as e:
self.logger.logger.error(f"Unexpected error checking cache status for {cache_file_path}: {e}")
return "stale" return "stale"
def query_domain(self, domain: str) -> ProviderResult: def query_domain(self, domain: str) -> ProviderResult:
""" """
FIXED: Query crt.sh for certificates containing the domain. FIXED: Simplified and more robust domain querying with better error handling.
Now properly creates domain and CA nodes instead of large entities. UPDATED: Enhanced with certificate timestamps for time-based edge coloring.
Args:
domain: Domain to investigate
Returns:
ProviderResult containing discovered relationships and attributes
""" """
if not _is_valid_domain(domain): if not _is_valid_domain(domain):
return ProviderResult() return ProviderResult()
@ -132,130 +141,158 @@ class CrtShProvider(BaseProvider):
return ProviderResult() return ProviderResult()
cache_file = self._get_cache_file_path(domain) cache_file = self._get_cache_file_path(domain)
cache_status = self._get_cache_status(cache_file)
result = ProviderResult() result = ProviderResult()
try: try:
cache_status = self._get_cache_status(cache_file)
if cache_status == "fresh": if cache_status == "fresh":
# Load from cache
result = self._load_from_cache(cache_file) result = self._load_from_cache(cache_file)
self.logger.logger.info(f"Using fresh cached crt.sh data for {domain}") if result and (result.relationships or result.attributes):
self.logger.logger.debug(f"Using fresh cached crt.sh data for {domain}")
return result
else:
# Cache exists but is empty, treat as stale
cache_status = "stale"
else: # "stale" or "not_found" # Need to query API (either no cache, stale cache, or empty cache)
# Query the API for the latest certificates self.logger.logger.debug(f"Querying crt.sh API for {domain} (cache status: {cache_status})")
new_raw_certs = self._query_crtsh(domain) new_raw_certs = self._query_crtsh_api(domain)
if self._stop_event and self._stop_event.is_set(): if self._stop_event and self._stop_event.is_set():
return ProviderResult() return ProviderResult()
# Combine with old data if cache is stale # FIXED: Simplified processing - just process the new data
if cache_status == "stale": # Don't try to merge with stale cache as it can cause corruption
old_raw_certs = self._load_raw_data_from_cache(cache_file) raw_certificates_to_process = new_raw_certs
combined_certs = old_raw_certs + new_raw_certs
# Deduplicate the combined list if cache_status == "stale":
seen_ids = set() self.logger.logger.info(f"Refreshed stale cache for {domain} with {len(raw_certificates_to_process)} certs")
unique_certs = []
for cert in combined_certs:
cert_id = cert.get('id')
if cert_id not in seen_ids:
unique_certs.append(cert)
seen_ids.add(cert_id)
raw_certificates_to_process = unique_certs
self.logger.logger.info(f"Refreshed and merged cache for {domain}. Total unique certs: {len(raw_certificates_to_process)}")
else: # "not_found"
raw_certificates_to_process = new_raw_certs
# FIXED: Process certificates to create proper domain and CA nodes
result = self._process_certificates_to_result_fixed(domain, raw_certificates_to_process)
self.logger.logger.info(f"Created fresh result for {domain} ({result.get_relationship_count()} relationships)")
# Save the new result and the raw data to the cache
self._save_result_to_cache(cache_file, result, raw_certificates_to_process, domain)
except (requests.exceptions.RequestException, psycopg2.Error) as e:
self.logger.logger.error(f"Upstream query failed for {domain}: {e}")
if cache_status != "not_found":
result = self._load_from_cache(cache_file)
self.logger.logger.warning(f"Using stale cache for {domain} due to API failure.")
else: else:
raise e # Re-raise if there's no cache to fall back on self.logger.logger.info(f"Created fresh cache for {domain} with {len(raw_certificates_to_process)} certs")
return result result = self._process_certificates_to_result_fixed(domain, raw_certificates_to_process)
# Save the result to cache
self._save_result_to_cache(cache_file, result, raw_certificates_to_process, domain)
return result
except requests.exceptions.RequestException as e:
# FIXED: Don't re-raise network errors after long idle periods
# Instead return empty result and log the issue
self.logger.logger.warning(f"Network error querying crt.sh for {domain}: {e}")
# Try to use stale cache if available
if cache_status == "stale":
try:
stale_result = self._load_from_cache(cache_file)
if stale_result and (stale_result.relationships or stale_result.attributes):
self.logger.logger.info(f"Using stale cache for {domain} due to network error")
return stale_result
except Exception as cache_error:
self.logger.logger.warning(f"Failed to load stale cache for {domain}: {cache_error}")
# Return empty result instead of raising - this prevents infinite retries
return ProviderResult()
except Exception as e:
# FIXED: Handle any other exceptions gracefully
self.logger.logger.error(f"Unexpected error querying crt.sh for {domain}: {e}")
# Try stale cache as fallback
try:
if cache_file.exists():
fallback_result = self._load_from_cache(cache_file)
if fallback_result and (fallback_result.relationships or fallback_result.attributes):
self.logger.logger.info(f"Using cached data for {domain} due to processing error")
return fallback_result
except Exception:
pass
# Return empty result to prevent retries
return ProviderResult()
def query_ip(self, ip: str) -> ProviderResult: def query_ip(self, ip: str) -> ProviderResult:
""" """
Query crt.sh for certificates containing the IP address. crt.sh does not support IP-based certificate queries effectively via its API.
Note: crt.sh doesn't typically index by IP, so this returns empty results.
Args:
ip: IP address to investigate
Returns:
Empty ProviderResult (crt.sh doesn't support IP-based certificate queries effectively)
""" """
return ProviderResult() return ProviderResult()
def _load_from_cache(self, cache_file_path: Path) -> ProviderResult: def _load_from_cache(self, cache_file_path: Path) -> ProviderResult:
"""Load processed crt.sh data from a cache file.""" """FIXED: More robust cache loading with better validation."""
try: try:
with open(cache_file_path, 'r') as f: if not cache_file_path.exists() or cache_file_path.stat().st_size == 0:
return ProviderResult()
with open(cache_file_path, 'r', encoding='utf-8') as f:
cache_content = json.load(f) cache_content = json.load(f)
if not isinstance(cache_content, dict):
self.logger.logger.warning(f"Invalid cache format in {cache_file_path}")
return ProviderResult()
result = ProviderResult() result = ProviderResult()
# Reconstruct relationships # Reconstruct relationships with validation
for rel_data in cache_content.get("relationships", []): relationships = cache_content.get("relationships", [])
result.add_relationship( if isinstance(relationships, list):
source_node=rel_data["source_node"], for rel_data in relationships:
target_node=rel_data["target_node"], if not isinstance(rel_data, dict):
relationship_type=rel_data["relationship_type"], continue
provider=rel_data["provider"], try:
confidence=rel_data["confidence"], result.add_relationship(
raw_data=rel_data.get("raw_data", {}) source_node=rel_data.get("source_node", ""),
) target_node=rel_data.get("target_node", ""),
relationship_type=rel_data.get("relationship_type", ""),
provider=rel_data.get("provider", self.name),
raw_data=rel_data.get("raw_data", {})
)
except (ValueError, TypeError) as e:
self.logger.logger.warning(f"Skipping invalid relationship in cache: {e}")
continue
# Reconstruct attributes # Reconstruct attributes with validation
for attr_data in cache_content.get("attributes", []): attributes = cache_content.get("attributes", [])
result.add_attribute( if isinstance(attributes, list):
target_node=attr_data["target_node"], for attr_data in attributes:
name=attr_data["name"], if not isinstance(attr_data, dict):
value=attr_data["value"], continue
attr_type=attr_data["type"], try:
provider=attr_data["provider"], result.add_attribute(
confidence=attr_data["confidence"], target_node=attr_data.get("target_node", ""),
metadata=attr_data.get("metadata", {}) name=attr_data.get("name", ""),
) value=attr_data.get("value"),
attr_type=attr_data.get("type", "unknown"),
provider=attr_data.get("provider", self.name),
metadata=attr_data.get("metadata", {})
)
except (ValueError, TypeError) as e:
self.logger.logger.warning(f"Skipping invalid attribute in cache: {e}")
continue
return result return result
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: except (json.JSONDecodeError, OSError, PermissionError) as e:
self.logger.logger.error(f"Failed to load cached certificates from {cache_file_path}: {e}") self.logger.logger.warning(f"Failed to load cache from {cache_file_path}: {e}")
return ProviderResult()
except Exception as e:
self.logger.logger.error(f"Unexpected error loading cache from {cache_file_path}: {e}")
return ProviderResult() return ProviderResult()
def _load_raw_data_from_cache(self, cache_file_path: Path) -> List[Dict[str, Any]]:
"""Load only the raw certificate data from a cache file."""
try:
with open(cache_file_path, 'r') as f:
cache_content = json.load(f)
return cache_content.get("raw_certificates", [])
except (json.JSONDecodeError, FileNotFoundError):
return []
def _save_result_to_cache(self, cache_file_path: Path, result: ProviderResult, raw_certificates: List[Dict[str, Any]], domain: str) -> None: def _save_result_to_cache(self, cache_file_path: Path, result: ProviderResult, raw_certificates: List[Dict[str, Any]], domain: str) -> None:
"""Save processed crt.sh result and raw data to a cache file.""" """FIXED: More robust cache saving with atomic writes."""
try: try:
cache_data = { cache_data = {
"domain": domain, "domain": domain,
"last_upstream_query": datetime.now(timezone.utc).isoformat(), "last_upstream_query": datetime.now(timezone.utc).isoformat(),
"raw_certificates": raw_certificates, # Store the raw data for deduplication "raw_certificates": raw_certificates,
"relationships": [ "relationships": [
{ {
"source_node": rel.source_node, "source_node": rel.source_node,
"target_node": rel.target_node, "target_node": rel.target_node,
"relationship_type": rel.relationship_type, "relationship_type": rel.relationship_type,
"confidence": rel.confidence,
"provider": rel.provider, "provider": rel.provider,
"raw_data": rel.raw_data "raw_data": rel.raw_data
} for rel in result.relationships } for rel in result.relationships
@ -267,92 +304,73 @@ class CrtShProvider(BaseProvider):
"value": attr.value, "value": attr.value,
"type": attr.type, "type": attr.type,
"provider": attr.provider, "provider": attr.provider,
"confidence": attr.confidence,
"metadata": attr.metadata "metadata": attr.metadata
} for attr in result.attributes } for attr in result.attributes
] ]
} }
cache_file_path.parent.mkdir(parents=True, exist_ok=True) cache_file_path.parent.mkdir(parents=True, exist_ok=True)
with open(cache_file_path, 'w') as f:
json.dump(cache_data, f, separators=(',', ':'), default=str) # FIXED: Atomic write using temporary file
temp_file = cache_file_path.with_suffix('.tmp')
try:
with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(cache_data, f, separators=(',', ':'), default=str, ensure_ascii=False)
# Atomic rename
temp_file.replace(cache_file_path)
self.logger.logger.debug(f"Saved cache for {domain} ({len(result.relationships)} relationships)")
except Exception as e:
# Clean up temp file on error
if temp_file.exists():
try:
temp_file.unlink()
except Exception:
pass
raise e
except Exception as e: except Exception as e:
self.logger.logger.warning(f"Failed to save cache file for {domain}: {e}") self.logger.logger.warning(f"Failed to save cache file for {domain}: {e}")
def _query_crtsh(self, domain: str) -> List[Dict[str, Any]]:
"""Query crt.sh, trying the database first and falling back to the API."""
global db_pool
if db_pool:
try:
self.logger.logger.info(f"crt.sh: Attempting DB query for {domain}")
return self._query_crtsh_db(domain)
except psycopg2.Error as e:
self.logger.logger.warning(f"crt.sh: DB query failed for {domain}: {e}. Falling back to HTTP API.")
return self._query_crtsh_api(domain)
else:
self.logger.logger.info(f"crt.sh: No DB connection pool. Using HTTP API for {domain}")
return self._query_crtsh_api(domain)
def _query_crtsh_db(self, domain: str) -> List[Dict[str, Any]]:
"""Query crt.sh database for raw certificate data."""
global db_pool
conn = db_pool.getconn()
try:
with conn.cursor() as cursor:
query = """
SELECT
c.id,
x509_serialnumber(c.certificate) as serial_number,
x509_notbefore(c.certificate) as not_before,
x509_notafter(c.certificate) as not_after,
c.issuer_ca_id,
ca.name as issuer_name,
x509_commonname(c.certificate) as common_name,
identities(c.certificate)::text as name_value
FROM certificate c
LEFT JOIN ca ON c.issuer_ca_id = ca.id
WHERE identities(c.certificate) @@ plainto_tsquery(%s)
ORDER BY c.id DESC
LIMIT 5000;
"""
cursor.execute(query, (domain,))
results = []
columns = [desc[0] for desc in cursor.description]
for row in cursor.fetchall():
row_dict = dict(zip(columns, row))
if row_dict.get('not_before'):
row_dict['not_before'] = row_dict['not_before'].isoformat()
if row_dict.get('not_after'):
row_dict['not_after'] = row_dict['not_after'].isoformat()
results.append(row_dict)
self.logger.logger.info(f"crt.sh: DB query for {domain} returned {len(results)} records.")
return results
finally:
db_pool.putconn(conn)
def _query_crtsh_api(self, domain: str) -> List[Dict[str, Any]]: def _query_crtsh_api(self, domain: str) -> List[Dict[str, Any]]:
"""Query crt.sh API for raw certificate data.""" """FIXED: More robust API querying with better error handling."""
url = f"{self.base_url}?q={quote(domain)}&output=json" url = f"{self.base_url}?q={quote(domain)}&output=json"
response = self.make_request(url, target_indicator=domain)
if not response or response.status_code != 200:
raise requests.exceptions.RequestException(f"crt.sh API returned status {response.status_code if response else 'None'}")
try: try:
certificates = response.json() response = self.make_request(url, target_indicator=domain)
except json.JSONDecodeError:
self.logger.logger.error(f"crt.sh returned invalid JSON for {domain}")
return []
if not certificates: if not response:
return [] self.logger.logger.warning(f"No response from crt.sh for {domain}")
return []
return certificates if response.status_code != 200:
self.logger.logger.warning(f"crt.sh returned status {response.status_code} for {domain}")
return []
# FIXED: Better JSON parsing with error handling
try:
certificates = response.json()
except json.JSONDecodeError as e:
self.logger.logger.error(f"crt.sh returned invalid JSON for {domain}: {e}")
return []
if not certificates or not isinstance(certificates, list):
self.logger.logger.debug(f"crt.sh returned no certificates for {domain}")
return []
self.logger.logger.debug(f"crt.sh returned {len(certificates)} certificates for {domain}")
return certificates
except Exception as e:
self.logger.logger.error(f"Error querying crt.sh API for {domain}: {e}")
raise e
def _process_certificates_to_result_fixed(self, query_domain: str, certificates: List[Dict[str, Any]]) -> ProviderResult: def _process_certificates_to_result_fixed(self, query_domain: str, certificates: List[Dict[str, Any]]) -> ProviderResult:
""" """
FIXED: Process certificates to create proper domain and CA nodes. Process certificates to create proper domain and CA nodes.
Now creates individual domain nodes instead of large entities. FIXED: Better error handling and progress tracking.
UPDATED: Enhanced with certificate timestamps for time-based edge coloring.
""" """
result = ProviderResult() result = ProviderResult()
@ -360,6 +378,11 @@ class CrtShProvider(BaseProvider):
self.logger.logger.info(f"CrtSh processing cancelled before processing for domain: {query_domain}") self.logger.logger.info(f"CrtSh processing cancelled before processing for domain: {query_domain}")
return result return result
if not certificates:
self.logger.logger.debug(f"No certificates to process for {query_domain}")
return result
# Check for incomplete data warning
incompleteness_warning = self._check_for_incomplete_data(query_domain, certificates) incompleteness_warning = self._check_for_incomplete_data(query_domain, certificates)
if incompleteness_warning: if incompleteness_warning:
result.add_attribute( result.add_attribute(
@ -367,61 +390,84 @@ class CrtShProvider(BaseProvider):
name="crtsh_data_warning", name="crtsh_data_warning",
value=incompleteness_warning, value=incompleteness_warning,
attr_type='metadata', attr_type='metadata',
provider=self.name, provider=self.name
confidence=1.0
) )
all_discovered_domains = set() all_discovered_domains = set()
processed_issuers = set() processed_issuers = set()
processed_certs = 0
for i, cert_data in enumerate(certificates): for i, cert_data in enumerate(certificates):
if i % 10 == 0 and self._stop_event and self._stop_event.is_set(): # FIXED: More frequent stop checks and progress logging
self.logger.logger.info(f"CrtSh processing cancelled at certificate {i} for domain: {query_domain}") if i % 5 == 0:
break if self._stop_event and self._stop_event.is_set():
self.logger.logger.info(f"CrtSh processing cancelled at certificate {i}/{len(certificates)} for domain: {query_domain}")
break
# Extract all domains from this certificate if i > 0 and i % 100 == 0:
cert_domains = self._extract_domains_from_certificate(cert_data) self.logger.logger.debug(f"Processed {i}/{len(certificates)} certificates for {query_domain}")
all_discovered_domains.update(cert_domains)
# FIXED: Create CA nodes for certificate issuers (not as domain metadata) try:
issuer_name = self._parse_issuer_organization(cert_data.get('issuer_name', '')) # Extract all domains from this certificate
if issuer_name and issuer_name not in processed_issuers: cert_domains = self._extract_domains_from_certificate(cert_data)
# Create relationship from query domain to CA if cert_domains:
result.add_relationship( all_discovered_domains.update(cert_domains)
source_node=query_domain,
target_node=issuer_name,
relationship_type='crtsh_cert_issuer',
provider=self.name,
confidence=0.95,
raw_data={'issuer_dn': cert_data.get('issuer_name', '')}
)
processed_issuers.add(issuer_name)
# Add certificate metadata to each domain in this certificate # Create CA nodes for certificate issuers with timestamp
cert_metadata = self._extract_certificate_metadata(cert_data) issuer_name = self._parse_issuer_organization(cert_data.get('issuer_name', ''))
for cert_domain in cert_domains: if issuer_name and issuer_name not in processed_issuers:
if not _is_valid_domain(cert_domain): # Enhanced raw_data with certificate timestamp for time-based edge coloring
continue issuer_raw_data = {'issuer_dn': cert_data.get('issuer_name', '')}
# Add certificate attributes to the domain # Add certificate issue date (not_before) as relevance timestamp
for key, value in cert_metadata.items(): not_before = cert_data.get('not_before')
if value is not None: if not_before:
result.add_attribute( try:
target_node=cert_domain, not_before_date = self._parse_certificate_date(not_before)
name=f"cert_{key}", issuer_raw_data['cert_not_before'] = not_before_date.isoformat()
value=value, issuer_raw_data['relevance_timestamp'] = not_before_date.isoformat() # Standardized field
attr_type='certificate_data', except Exception as e:
provider=self.name, self.logger.logger.debug(f"Failed to parse not_before date for issuer: {e}")
confidence=0.9,
metadata={'certificate_id': cert_data.get('id')}
)
result.add_relationship(
source_node=query_domain,
target_node=issuer_name,
relationship_type='crtsh_cert_issuer',
provider=self.name,
raw_data=issuer_raw_data
)
processed_issuers.add(issuer_name)
# Add certificate metadata to each domain in this certificate
cert_metadata = self._extract_certificate_metadata(cert_data)
for cert_domain in cert_domains:
if not _is_valid_domain(cert_domain):
continue
for key, value in cert_metadata.items():
if value is not None:
result.add_attribute(
target_node=cert_domain,
name=f"cert_{key}",
value=value,
attr_type='certificate_data',
provider=self.name,
metadata={'certificate_id': cert_data.get('id')}
)
processed_certs += 1
except Exception as e:
self.logger.logger.warning(f"Error processing certificate {i} for {query_domain}: {e}")
continue
# Check for stop event before creating final relationships
if self._stop_event and self._stop_event.is_set(): if self._stop_event and self._stop_event.is_set():
self.logger.logger.info(f"CrtSh query cancelled before relationship creation for domain: {query_domain}") self.logger.logger.info(f"CrtSh query cancelled before relationship creation for domain: {query_domain}")
return result return result
# FIXED: Create selective relationships to avoid large entities # Create selective relationships to avoid large entities with enhanced timestamps
# Only create relationships to domains that are closely related relationships_created = 0
for discovered_domain in all_discovered_domains: for discovered_domain in all_discovered_domains:
if discovered_domain == query_domain: if discovered_domain == query_domain:
continue continue
@ -429,48 +475,80 @@ class CrtShProvider(BaseProvider):
if not _is_valid_domain(discovered_domain): if not _is_valid_domain(discovered_domain):
continue continue
# FIXED: Only create relationships for domains that share a meaningful connection
# This prevents creating too many relationships that trigger large entity creation
if self._should_create_relationship(query_domain, discovered_domain): if self._should_create_relationship(query_domain, discovered_domain):
confidence = self._calculate_domain_relationship_confidence( # Enhanced raw_data with certificate timestamp for domain relationships
query_domain, discovered_domain, [], all_discovered_domains domain_raw_data = {'relationship_type': 'certificate_discovery'}
# Find the most recent certificate for this domain pair to use as timestamp
most_recent_cert = self._find_most_recent_cert_for_domains(
certificates, query_domain, discovered_domain
) )
if most_recent_cert:
not_before = most_recent_cert.get('not_before')
if not_before:
try:
not_before_date = self._parse_certificate_date(not_before)
domain_raw_data['cert_not_before'] = not_before_date.isoformat()
domain_raw_data['relevance_timestamp'] = not_before_date.isoformat()
except Exception as e:
self.logger.logger.debug(f"Failed to parse not_before date for domain relationship: {e}")
result.add_relationship( result.add_relationship(
source_node=query_domain, source_node=query_domain,
target_node=discovered_domain, target_node=discovered_domain,
relationship_type='crtsh_san_certificate', relationship_type='crtsh_san_certificate',
provider=self.name, provider=self.name,
confidence=confidence, raw_data=domain_raw_data
raw_data={'relationship_type': 'certificate_discovery'}
) )
self.log_relationship_discovery( self.log_relationship_discovery(
source_node=query_domain, source_node=query_domain,
target_node=discovered_domain, target_node=discovered_domain,
relationship_type='crtsh_san_certificate', relationship_type='crtsh_san_certificate',
confidence_score=confidence, raw_data=domain_raw_data,
raw_data={'relationship_type': 'certificate_discovery'},
discovery_method="certificate_transparency_analysis" discovery_method="certificate_transparency_analysis"
) )
relationships_created += 1
self.logger.logger.info(f"CrtSh processing completed for {query_domain}: {len(all_discovered_domains)} domains, {result.get_relationship_count()} relationships") self.logger.logger.info(f"CrtSh processing completed for {query_domain}: processed {processed_certs}/{len(certificates)} certificates, {len(all_discovered_domains)} domains, {relationships_created} relationships")
return result return result
def _find_most_recent_cert_for_domains(self, certificates: List[Dict[str, Any]],
domain1: str, domain2: str) -> Optional[Dict[str, Any]]:
"""
Find the most recent certificate that contains both domains.
Used for determining the relevance timestamp for domain relationships.
"""
most_recent_cert = None
most_recent_date = None
for cert in certificates:
# Check if this certificate contains both domains
cert_domains = self._extract_domains_from_certificate(cert)
if domain1 in cert_domains and domain2 in cert_domains:
not_before = cert.get('not_before')
if not_before:
try:
cert_date = self._parse_certificate_date(not_before)
if most_recent_date is None or cert_date > most_recent_date:
most_recent_date = cert_date
most_recent_cert = cert
except Exception:
continue
return most_recent_cert
# [Rest of the methods remain the same as in the original file]
def _should_create_relationship(self, source_domain: str, target_domain: str) -> bool: def _should_create_relationship(self, source_domain: str, target_domain: str) -> bool:
""" """
FIXED: Determine if a relationship should be created between two domains. Determine if a relationship should be created between two domains.
This helps avoid creating too many relationships that trigger large entity creation.
""" """
# Always create relationships for subdomains
if target_domain.endswith(f'.{source_domain}') or source_domain.endswith(f'.{target_domain}'): if target_domain.endswith(f'.{source_domain}') or source_domain.endswith(f'.{target_domain}'):
return True return True
# Create relationships for domains that share a common parent (up to 2 levels)
source_parts = source_domain.split('.') source_parts = source_domain.split('.')
target_parts = target_domain.split('.') target_parts = target_domain.split('.')
# Check if they share the same root domain (last 2 parts)
if len(source_parts) >= 2 and len(target_parts) >= 2: if len(source_parts) >= 2 and len(target_parts) >= 2:
source_root = '.'.join(source_parts[-2:]) source_root = '.'.join(source_parts[-2:])
target_root = '.'.join(target_parts[-2:]) target_root = '.'.join(target_parts[-2:])
@ -504,7 +582,6 @@ class CrtShProvider(BaseProvider):
metadata['is_currently_valid'] = self._is_cert_valid(cert_data) metadata['is_currently_valid'] = self._is_cert_valid(cert_data)
metadata['expires_soon'] = (not_after - datetime.now(timezone.utc)).days <= 30 metadata['expires_soon'] = (not_after - datetime.now(timezone.utc)).days <= 30
# Keep raw date format or convert to standard format
metadata['not_before'] = not_before.isoformat() metadata['not_before'] = not_before.isoformat()
metadata['not_after'] = not_after.isoformat() metadata['not_after'] = not_after.isoformat()
@ -586,14 +663,12 @@ class CrtShProvider(BaseProvider):
"""Extract all domains from certificate data.""" """Extract all domains from certificate data."""
domains = set() domains = set()
# Extract from common name
common_name = cert_data.get('common_name', '') common_name = cert_data.get('common_name', '')
if common_name: if common_name:
cleaned_cn = self._clean_domain_name(common_name) cleaned_cn = self._clean_domain_name(common_name)
if cleaned_cn: if cleaned_cn:
domains.update(cleaned_cn) domains.update(cleaned_cn)
# Extract from name_value field (contains SANs)
name_value = cert_data.get('name_value', '') name_value = cert_data.get('name_value', '')
if name_value: if name_value:
for line in name_value.split('\n'): for line in name_value.split('\n'):
@ -634,26 +709,6 @@ class CrtShProvider(BaseProvider):
return [d for d in final_domains if _is_valid_domain(d)] return [d for d in final_domains if _is_valid_domain(d)]
def _calculate_domain_relationship_confidence(self, domain1: str, domain2: str,
shared_certificates: List[Dict[str, Any]],
all_discovered_domains: Set[str]) -> float:
"""Calculate confidence score for domain relationship based on various factors."""
base_confidence = 0.9
# Adjust confidence based on domain relationship context
relationship_context = self._determine_relationship_context(domain2, domain1)
if relationship_context == 'exact_match':
context_bonus = 0.0
elif relationship_context == 'subdomain':
context_bonus = 0.1
elif relationship_context == 'parent_domain':
context_bonus = 0.05
else:
context_bonus = 0.0
final_confidence = base_confidence + context_bonus
return max(0.1, min(1.0, final_confidence))
def _determine_relationship_context(self, cert_domain: str, query_domain: str) -> str: def _determine_relationship_context(self, cert_domain: str, query_domain: str) -> str:
"""Determine the context of the relationship between certificate domain and query domain.""" """Determine the context of the relationship between certificate domain and query domain."""
@ -672,12 +727,10 @@ class CrtShProvider(BaseProvider):
""" """
cert_count = len(certificates) cert_count = len(certificates)
# Heuristic 1: Check if the number of certs hits a known hard limit.
if cert_count >= 10000: if cert_count >= 10000:
return f"Result likely truncated; received {cert_count} certificates, which may be the maximum limit." return f"Result likely truncated; received {cert_count} certificates, which may be the maximum limit."
# Heuristic 2: Check if all returned certificates are old. if cert_count > 1000:
if cert_count > 1000: # Only apply this for a reasonable number of certs
latest_expiry = None latest_expiry = None
for cert in certificates: for cert in certificates:
try: try:

View File

@ -1,7 +1,8 @@
# dnsrecon/providers/dns_provider.py # DNScope/providers/dns_provider.py
from dns import resolver, reversename from dns import resolver, reversename
from typing import Dict from typing import Dict
from datetime import datetime, timezone
from .base_provider import BaseProvider from .base_provider import BaseProvider
from core.provider_result import ProviderResult from core.provider_result import ProviderResult
from utils.helpers import _is_valid_ip, _is_valid_domain, get_ip_version from utils.helpers import _is_valid_ip, _is_valid_domain, get_ip_version
@ -11,6 +12,7 @@ class DNSProvider(BaseProvider):
""" """
Provider for standard DNS resolution and reverse DNS lookups. Provider for standard DNS resolution and reverse DNS lookups.
Now returns standardized ProviderResult objects with IPv4 and IPv6 support. Now returns standardized ProviderResult objects with IPv4 and IPv6 support.
UPDATED: Enhanced with discovery timestamps for time-based edge coloring.
""" """
def __init__(self, name=None, session_config=None): def __init__(self, name=None, session_config=None):
@ -51,6 +53,7 @@ class DNSProvider(BaseProvider):
""" """
Query DNS records for the domain to discover relationships and attributes. Query DNS records for the domain to discover relationships and attributes.
FIXED: Now creates separate attributes for each DNS record type. FIXED: Now creates separate attributes for each DNS record type.
UPDATED: Enhanced with discovery timestamps for time-based edge coloring.
Args: Args:
domain: Domain to investigate domain: Domain to investigate
@ -62,11 +65,12 @@ class DNSProvider(BaseProvider):
return ProviderResult() return ProviderResult()
result = ProviderResult() result = ProviderResult()
discovery_time = datetime.now(timezone.utc)
# Query all record types - each gets its own attribute # Query all record types - each gets its own attribute
for record_type in ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'SOA', 'TXT', 'SRV', 'CAA']: for record_type in ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'SOA', 'TXT', 'SRV', 'CAA']:
try: try:
self._query_record(domain, record_type, result) self._query_record(domain, record_type, result, discovery_time)
#except resolver.NoAnswer: #except resolver.NoAnswer:
# This is not an error, just a confirmation that the record doesn't exist. # This is not an error, just a confirmation that the record doesn't exist.
#self.logger.logger.debug(f"No {record_type} record found for {domain}") #self.logger.logger.debug(f"No {record_type} record found for {domain}")
@ -79,6 +83,7 @@ class DNSProvider(BaseProvider):
def query_ip(self, ip: str) -> ProviderResult: def query_ip(self, ip: str) -> ProviderResult:
""" """
Query reverse DNS for the IP address (supports both IPv4 and IPv6). Query reverse DNS for the IP address (supports both IPv4 and IPv6).
UPDATED: Enhanced with discovery timestamps for time-based edge coloring.
Args: Args:
ip: IP address to investigate (IPv4 or IPv6) ip: IP address to investigate (IPv4 or IPv6)
@ -91,6 +96,7 @@ class DNSProvider(BaseProvider):
result = ProviderResult() result = ProviderResult()
ip_version = get_ip_version(ip) ip_version = get_ip_version(ip)
discovery_time = datetime.now(timezone.utc)
try: try:
# Perform reverse DNS lookup (works for both IPv4 and IPv6) # Perform reverse DNS lookup (works for both IPv4 and IPv6)
@ -112,20 +118,24 @@ class DNSProvider(BaseProvider):
relationship_type = 'dns_a_record' relationship_type = 'dns_a_record'
record_prefix = 'A' record_prefix = 'A'
# Enhanced raw_data with discovery timestamp for time-based edge coloring
raw_data = {
'query_type': 'PTR',
'ip_address': ip,
'ip_version': ip_version,
'hostname': hostname,
'ttl': response.ttl,
'discovery_timestamp': discovery_time.isoformat(),
'relevance_timestamp': discovery_time.isoformat() # DNS data is "fresh" when discovered
}
# Add the relationship # Add the relationship
result.add_relationship( result.add_relationship(
source_node=ip, source_node=ip,
target_node=hostname, target_node=hostname,
relationship_type='dns_ptr_record', relationship_type='dns_ptr_record',
provider=self.name, provider=self.name,
confidence=0.8, raw_data=raw_data
raw_data={
'query_type': 'PTR',
'ip_address': ip,
'ip_version': ip_version,
'hostname': hostname,
'ttl': response.ttl
}
) )
# Add to PTR records list # Add to PTR records list
@ -136,14 +146,7 @@ class DNSProvider(BaseProvider):
source_node=ip, source_node=ip,
target_node=hostname, target_node=hostname,
relationship_type='dns_ptr_record', relationship_type='dns_ptr_record',
confidence_score=0.8, raw_data=raw_data,
raw_data={
'query_type': 'PTR',
'ip_address': ip,
'ip_version': ip_version,
'hostname': hostname,
'ttl': response.ttl
},
discovery_method=f"reverse_dns_lookup_ipv{ip_version}" discovery_method=f"reverse_dns_lookup_ipv{ip_version}"
) )
@ -155,7 +158,6 @@ class DNSProvider(BaseProvider):
value=ptr_records, value=ptr_records,
attr_type='dns_record', attr_type='dns_record',
provider=self.name, provider=self.name,
confidence=0.8,
metadata={'ttl': response.ttl, 'ip_version': ip_version} metadata={'ttl': response.ttl, 'ip_version': ip_version}
) )
@ -170,10 +172,11 @@ class DNSProvider(BaseProvider):
return result return result
def _query_record(self, domain: str, record_type: str, result: ProviderResult) -> None: def _query_record(self, domain: str, record_type: str, result: ProviderResult, discovery_time: datetime) -> None:
""" """
FIXED: Query DNS records with unique attribute names for each record type. FIXED: Query DNS records with unique attribute names for each record type.
Enhanced to better handle IPv6 AAAA records. Enhanced to better handle IPv6 AAAA records.
UPDATED: Enhanced with discovery timestamps for time-based edge coloring.
""" """
try: try:
self.total_requests += 1 self.total_requests += 1
@ -217,18 +220,20 @@ class DNSProvider(BaseProvider):
if record_type in ['A', 'AAAA'] and _is_valid_ip(target): if record_type in ['A', 'AAAA'] and _is_valid_ip(target):
ip_version = get_ip_version(target) ip_version = get_ip_version(target)
# Enhanced raw_data with discovery timestamp for time-based edge coloring
raw_data = { raw_data = {
'query_type': record_type, 'query_type': record_type,
'domain': domain, 'domain': domain,
'value': target, 'value': target,
'ttl': response.ttl 'ttl': response.ttl,
'discovery_timestamp': discovery_time.isoformat(),
'relevance_timestamp': discovery_time.isoformat() # DNS data is "fresh" when discovered
} }
if ip_version: if ip_version:
raw_data['ip_version'] = ip_version raw_data['ip_version'] = ip_version
relationship_type = f"dns_{record_type.lower()}_record" relationship_type = f"dns_{record_type.lower()}_record"
confidence = 0.8
# Add relationship # Add relationship
result.add_relationship( result.add_relationship(
@ -236,7 +241,6 @@ class DNSProvider(BaseProvider):
target_node=target, target_node=target,
relationship_type=relationship_type, relationship_type=relationship_type,
provider=self.name, provider=self.name,
confidence=confidence,
raw_data=raw_data raw_data=raw_data
) )
@ -252,7 +256,6 @@ class DNSProvider(BaseProvider):
source_node=domain, source_node=domain,
target_node=target, target_node=target,
relationship_type=relationship_type, relationship_type=relationship_type,
confidence_score=confidence,
raw_data=raw_data, raw_data=raw_data,
discovery_method=discovery_method discovery_method=discovery_method
) )
@ -276,7 +279,6 @@ class DNSProvider(BaseProvider):
value=dns_records, value=dns_records,
attr_type='dns_record_list', attr_type='dns_record_list',
provider=self.name, provider=self.name,
confidence=0.8,
metadata=metadata metadata=metadata
) )

View File

@ -1,4 +1,4 @@
# dnsrecon/providers/shodan_provider.py # DNScope/providers/shodan_provider.py
import json import json
from pathlib import Path from pathlib import Path
@ -15,6 +15,7 @@ class ShodanProvider(BaseProvider):
""" """
Provider for querying Shodan API for IP address information. Provider for querying Shodan API for IP address information.
Now returns standardized ProviderResult objects with caching support for IPv4 and IPv6. Now returns standardized ProviderResult objects with caching support for IPv4 and IPv6.
UPDATED: Enhanced with last_seen timestamp for time-based edge coloring.
""" """
def __init__(self, name=None, session_config=None): def __init__(self, name=None, session_config=None):
@ -145,6 +146,7 @@ class ShodanProvider(BaseProvider):
""" """
Query Shodan for information about an IP address (IPv4 or IPv6), with caching of processed data. Query Shodan for information about an IP address (IPv4 or IPv6), with caching of processed data.
FIXED: Proper 404 handling to prevent unnecessary retries. FIXED: Proper 404 handling to prevent unnecessary retries.
UPDATED: Enhanced with last_seen timestamp extraction for time-based edge coloring.
Args: Args:
ip: IP address to investigate (IPv4 or IPv6) ip: IP address to investigate (IPv4 or IPv6)
@ -304,7 +306,6 @@ class ShodanProvider(BaseProvider):
target_node=rel_data["target_node"], target_node=rel_data["target_node"],
relationship_type=rel_data["relationship_type"], relationship_type=rel_data["relationship_type"],
provider=rel_data["provider"], provider=rel_data["provider"],
confidence=rel_data["confidence"],
raw_data=rel_data.get("raw_data", {}) raw_data=rel_data.get("raw_data", {})
) )
@ -316,7 +317,6 @@ class ShodanProvider(BaseProvider):
value=attr_data["value"], value=attr_data["value"],
attr_type=attr_data["type"], attr_type=attr_data["type"],
provider=attr_data["provider"], provider=attr_data["provider"],
confidence=attr_data["confidence"],
metadata=attr_data.get("metadata", {}) metadata=attr_data.get("metadata", {})
) )
@ -336,7 +336,6 @@ class ShodanProvider(BaseProvider):
"source_node": rel.source_node, "source_node": rel.source_node,
"target_node": rel.target_node, "target_node": rel.target_node,
"relationship_type": rel.relationship_type, "relationship_type": rel.relationship_type,
"confidence": rel.confidence,
"provider": rel.provider, "provider": rel.provider,
"raw_data": rel.raw_data "raw_data": rel.raw_data
} for rel in result.relationships } for rel in result.relationships
@ -348,7 +347,6 @@ class ShodanProvider(BaseProvider):
"value": attr.value, "value": attr.value,
"type": attr.type, "type": attr.type,
"provider": attr.provider, "provider": attr.provider,
"confidence": attr.confidence,
"metadata": attr.metadata "metadata": attr.metadata
} for attr in result.attributes } for attr in result.attributes
] ]
@ -362,25 +360,40 @@ class ShodanProvider(BaseProvider):
""" """
VERIFIED: Process Shodan data creating ISP nodes with ASN attributes and proper relationships. VERIFIED: Process Shodan data creating ISP nodes with ASN attributes and proper relationships.
Enhanced to include IP version information for IPv6 addresses. Enhanced to include IP version information for IPv6 addresses.
UPDATED: Enhanced with last_seen timestamp for time-based edge coloring.
""" """
result = ProviderResult() result = ProviderResult()
# Determine IP version for metadata # Determine IP version for metadata
ip_version = get_ip_version(ip) ip_version = get_ip_version(ip)
# Extract last_seen timestamp for time-based edge coloring
last_seen = data.get('last_seen')
# VERIFIED: Extract ISP information and create proper ISP node with ASN # VERIFIED: Extract ISP information and create proper ISP node with ASN
isp_name = data.get('org') isp_name = data.get('org')
asn_value = data.get('asn') asn_value = data.get('asn')
if isp_name and asn_value: if isp_name and asn_value:
# Enhanced raw_data with last_seen timestamp
raw_data = {
'asn': asn_value,
'shodan_org': isp_name,
'ip_version': ip_version
}
# Add last_seen timestamp if available
if last_seen:
raw_data['last_seen'] = last_seen
raw_data['relevance_timestamp'] = last_seen # Standardized field for time-based coloring
# Create relationship from IP to ISP # Create relationship from IP to ISP
result.add_relationship( result.add_relationship(
source_node=ip, source_node=ip,
target_node=isp_name, target_node=isp_name,
relationship_type='shodan_isp', relationship_type='shodan_isp',
provider=self.name, provider=self.name,
confidence=0.9, raw_data=raw_data
raw_data={'asn': asn_value, 'shodan_org': isp_name, 'ip_version': ip_version}
) )
# Add ASN as attribute to the ISP node # Add ASN as attribute to the ISP node
@ -390,7 +403,6 @@ class ShodanProvider(BaseProvider):
value=asn_value, value=asn_value,
attr_type='isp_info', attr_type='isp_info',
provider=self.name, provider=self.name,
confidence=0.9,
metadata={'description': 'Autonomous System Number from Shodan', 'ip_version': ip_version} metadata={'description': 'Autonomous System Number from Shodan', 'ip_version': ip_version}
) )
@ -401,7 +413,6 @@ class ShodanProvider(BaseProvider):
value=isp_name, value=isp_name,
attr_type='isp_info', attr_type='isp_info',
provider=self.name, provider=self.name,
confidence=0.9,
metadata={'description': 'Organization name from Shodan', 'ip_version': ip_version} metadata={'description': 'Organization name from Shodan', 'ip_version': ip_version}
) )
@ -416,20 +427,24 @@ class ShodanProvider(BaseProvider):
else: else:
relationship_type = 'shodan_a_record' relationship_type = 'shodan_a_record'
# Enhanced raw_data with last_seen timestamp
hostname_raw_data = {**data, 'ip_version': ip_version}
if last_seen:
hostname_raw_data['last_seen'] = last_seen
hostname_raw_data['relevance_timestamp'] = last_seen
result.add_relationship( result.add_relationship(
source_node=ip, source_node=ip,
target_node=hostname, target_node=hostname,
relationship_type=relationship_type, relationship_type=relationship_type,
provider=self.name, provider=self.name,
confidence=0.8, raw_data=hostname_raw_data
raw_data={**data, 'ip_version': ip_version}
) )
self.log_relationship_discovery( self.log_relationship_discovery(
source_node=ip, source_node=ip,
target_node=hostname, target_node=hostname,
relationship_type=relationship_type, relationship_type=relationship_type,
confidence_score=0.8, raw_data=hostname_raw_data,
raw_data={**data, 'ip_version': ip_version},
discovery_method=f"shodan_host_lookup_ipv{ip_version}" discovery_method=f"shodan_host_lookup_ipv{ip_version}"
) )
elif key == 'ports': elif key == 'ports':
@ -441,7 +456,6 @@ class ShodanProvider(BaseProvider):
value=port, value=port,
attr_type='shodan_network_info', attr_type='shodan_network_info',
provider=self.name, provider=self.name,
confidence=0.9,
metadata={'ip_version': ip_version} metadata={'ip_version': ip_version}
) )
elif isinstance(value, (str, int, float, bool)) and value is not None: elif isinstance(value, (str, int, float, bool)) and value is not None:
@ -452,7 +466,6 @@ class ShodanProvider(BaseProvider):
value=value, value=value,
attr_type='shodan_info', attr_type='shodan_info',
provider=self.name, provider=self.name,
confidence=0.9,
metadata={'ip_version': ip_version} metadata={'ip_version': ip_version}
) )

View File

@ -1,4 +1,4 @@
/* DNSRecon - Optimized Compact Theme */ /* DNScope - Optimized Compact Theme */
/* Reset and Base */ /* Reset and Base */
* { * {
@ -326,6 +326,20 @@ input[type="text"]:focus, select:focus {
animation: progressGlow 2s ease-in-out infinite alternate; animation: progressGlow 2s ease-in-out infinite alternate;
} }
.gradient-bar {
height: 4px;
background: linear-gradient(to right, #6b7280, #00bfff);
border-radius: 2px;
margin: 0.2rem 0;
}
.gradient-labels {
display: flex;
justify-content: space-between;
font-size: 0.6rem;
color: #888;
}
@keyframes progressShimmer { @keyframes progressShimmer {
0% { transform: translateX(-100%); } 0% { transform: translateX(-100%); }
100% { transform: translateX(100%); } 100% { transform: translateX(100%); }
@ -380,32 +394,59 @@ input[type="text"]:focus, select:focus {
color: #999; color: #999;
} }
/* Graph Controls */ /* Enhanced graph controls layout */
.graph-controls { .graph-controls {
position: absolute;
top: 8px;
right: 8px;
z-index: 10;
display: flex; display: flex;
flex-direction: column;
gap: 0.3rem; gap: 0.3rem;
position: absolute;
top: 10px;
right: 10px;
background: rgba(26, 26, 26, 0.9);
padding: 0.5rem;
border-radius: 6px;
border: 1px solid #444;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
z-index: 100;
min-width: 200px;
} }
.graph-control-btn, .btn-icon-small { .graph-control-btn {
background: rgba(42, 42, 42, 0.9); background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%);
border: 1px solid #555; border: 1px solid #555;
color: #c7c7c7; color: #c7c7c7;
padding: 0.3rem 0.5rem; padding: 0.4rem 0.8rem;
font-family: 'Roboto Mono', monospace; border-radius: 4px;
font-size: 0.7rem;
cursor: pointer; cursor: pointer;
transition: all 0.3s ease; font-family: 'Roboto Mono', monospace;
font-size: 0.8rem;
transition: all 0.2s ease;
text-align: center;
} }
.graph-control-btn:hover, .btn-icon-small:hover { .graph-control-btn:hover {
background: linear-gradient(135deg, #3a3a3a 0%, #2e2e2e 100%);
border-color: #00ff41; border-color: #00ff41;
color: #00ff41; color: #00ff41;
} }
.graph-control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.manual-refresh-btn {
background: linear-gradient(135deg, #4a4a2a 0%, #3e3e1e 100%);
border-color: #ffaa00;
color: #ffaa00;
}
.manual-refresh-btn:hover {
background: linear-gradient(135deg, #5a5a3a 0%, #4e4e2e 100%);
color: #ffcc33;
border-color: #ffcc33;
}
.graph-filter-panel { .graph-filter-panel {
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;
@ -474,6 +515,7 @@ input[type="text"]:focus, select:focus {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
align-items: center; align-items: center;
max-height: 3rem;
} }
.legend-item { .legend-item {
@ -499,14 +541,6 @@ input[type="text"]:focus, select:focus {
height: 2px; height: 2px;
} }
.legend-edge.high-confidence {
background: #00ff41;
}
.legend-edge.medium-confidence {
background: #ff9900;
}
/* Provider Panel */ /* Provider Panel */
.provider-panel { .provider-panel {
grid-area: providers; grid-area: providers;
@ -986,11 +1020,6 @@ input[type="text"]:focus, select:focus {
border-radius: 2px; border-radius: 2px;
} }
.confidence-indicator {
font-size: 0.6rem;
letter-spacing: 1px;
}
.node-link-compact { .node-link-compact {
color: #00aaff; color: #00aaff;
text-decoration: none; text-decoration: none;
@ -1094,6 +1123,56 @@ input[type="text"]:focus, select:focus {
border-left: 3px solid #00aaff; border-left: 3px solid #00aaff;
} }
.time-control-container {
margin-bottom: 0.5rem;
padding: 0.5rem;
background: rgba(42, 42, 42, 0.3);
border-radius: 4px;
border: 1px solid #444;
}
.time-control-label {
font-size: 0.8rem;
color: #c7c7c7;
margin-bottom: 0.3rem;
display: block;
font-family: 'Roboto Mono', monospace;
}
.time-control-input {
width: 100%;
padding: 0.3rem;
background: #1a1a1a;
border: 1px solid #555;
border-radius: 3px;
color: #c7c7c7;
font-family: 'Roboto Mono', monospace;
font-size: 0.75rem;
}
.time-control-input:focus {
outline: none;
border-color: #00ff41;
box-shadow: 0 0 5px rgba(0, 255, 65, 0.3);
}
.time-gradient-info {
font-size: 0.7rem;
color: #999;
margin-top: 0.3rem;
text-align: center;
font-family: 'Roboto Mono', monospace;
}
/* Edge color legend for time-based gradient */
.time-gradient-legend {
margin-top: 0.5rem;
padding: 0.3rem;
background: rgba(26, 26, 26, 0.5);
border-radius: 3px;
border: 1px solid #333;
}
/* Settings Modal Specific */ /* Settings Modal Specific */
.provider-toggle { .provider-toggle {
appearance: none !important; appearance: none !important;
@ -1323,4 +1402,16 @@ input[type="password"]:focus {
.provider-list { .provider-list {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.graph-controls {
position: relative;
top: auto;
right: auto;
margin-bottom: 1rem;
min-width: auto;
}
.time-control-input {
font-size: 0.7rem;
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,16 @@
/** /**
* Main application logic for DNSRecon web interface * Main application logic for DNScope web interface
* Handles UI interactions, API communication, and data flow * Handles UI interactions, API communication, and data flow
* UPDATED: Now compatible with a strictly flat, unified data model for attributes. * UPDATED: Now compatible with a strictly flat, unified data model for attributes.
*/ */
class DNSReconApp { class DNScopeApp {
constructor() { constructor() {
console.log('DNSReconApp constructor called'); console.log('DNScopeApp constructor called');
this.graphManager = null; this.graphManager = null;
this.scanStatus = 'idle'; this.scanStatus = 'idle';
this.pollInterval = null; this.statusPollInterval = null; // Separate status polling
this.graphPollInterval = null; // Separate graph polling
this.currentSessionId = null; this.currentSessionId = null;
this.elements = {}; this.elements = {};
@ -17,6 +18,10 @@ class DNSReconApp {
this.isScanning = false; this.isScanning = false;
this.lastGraphUpdate = null; this.lastGraphUpdate = null;
// Graph polling optimization
this.graphPollingNodeThreshold = 500; // Default, will be loaded from config
this.graphPollingEnabled = true;
this.init(); this.init();
} }
@ -24,7 +29,7 @@ class DNSReconApp {
* Initialize the application * Initialize the application
*/ */
init() { init() {
console.log('DNSReconApp init called'); console.log('DNScopeApp init called');
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
console.log('DOM loaded, initializing application...'); console.log('DOM loaded, initializing application...');
try { try {
@ -35,17 +40,33 @@ class DNSReconApp {
this.loadProviders(); this.loadProviders();
this.initializeEnhancedModals(); this.initializeEnhancedModals();
this.addCheckboxStyling(); this.addCheckboxStyling();
this.loadConfig(); // Load configuration including threshold
this.updateGraph(); this.updateGraph();
console.log('DNSRecon application initialized successfully'); console.log('DNScope application initialized successfully');
} catch (error) { } catch (error) {
console.error('Failed to initialize DNSRecon application:', error); console.error('Failed to initialize DNScope application:', error);
this.showError(`Initialization failed: ${error.message}`); this.showError(`Initialization failed: ${error.message}`);
} }
}); });
} }
/**
* Load configuration from backend
*/
async loadConfig() {
try {
const response = await this.apiCall('/api/config');
if (response.success) {
this.graphPollingNodeThreshold = response.config.graph_polling_node_threshold;
console.log(`Graph polling threshold set to: ${this.graphPollingNodeThreshold} nodes`);
}
} catch (error) {
console.warn('Failed to load config, using defaults:', error);
}
}
/** /**
* Initialize DOM element references * Initialize DOM element references
*/ */
@ -203,12 +224,6 @@ class DNSReconApp {
if (e.target === this.elements.settingsModal) this.hideSettingsModal(); if (e.target === this.elements.settingsModal) this.hideSettingsModal();
}); });
} }
if (this.elements.saveApiKeys) {
this.elements.saveApiKeys.removeEventListener('click', this.saveApiKeys);
}
if (this.elements.resetApiKeys) {
this.elements.resetApiKeys.removeEventListener('click', this.resetApiKeys);
}
// Setup new handlers // Setup new handlers
const saveSettingsBtn = document.getElementById('save-settings'); const saveSettingsBtn = document.getElementById('save-settings');
@ -263,12 +278,19 @@ class DNSReconApp {
} }
/** /**
* Initialize graph visualization * Initialize graph visualization with manual refresh button
*/ */
initializeGraph() { initializeGraph() {
try { try {
console.log('Initializing graph manager...'); console.log('Initializing graph manager...');
this.graphManager = new GraphManager('network-graph'); this.graphManager = new GraphManager('network-graph');
// Set up manual refresh handler
this.graphManager.setManualRefreshHandler(() => {
console.log('Manual graph refresh requested');
this.updateGraph();
});
console.log('Graph manager initialized successfully'); console.log('Graph manager initialized successfully');
} catch (error) { } catch (error) {
console.error('Failed to initialize graph manager:', error); console.error('Failed to initialize graph manager:', error);
@ -276,6 +298,34 @@ class DNSReconApp {
} }
} }
/**
* Check if graph polling should be enabled based on node count
*/
shouldEnableGraphPolling() {
if (!this.graphManager || !this.graphManager.nodes) {
return true;
}
const nodeCount = this.graphManager.nodes.length;
return nodeCount <= this.graphPollingNodeThreshold;
}
/**
* Update manual refresh button visibility and state.
* The button will be visible whenever auto-polling is disabled,
* and enabled only when a scan is in progress.
*/
updateManualRefreshButton() {
if (!this.graphManager || !this.graphManager.manualRefreshButton) return;
const shouldShow = !this.graphPollingEnabled;
this.graphManager.showManualRefreshButton(shouldShow);
if (shouldShow) {
this.graphManager.manualRefreshButton.disabled = false;
}
}
/** /**
* Start scan with error handling * Start scan with error handling
*/ */
@ -324,18 +374,21 @@ class DNSReconApp {
if (clearGraph) { if (clearGraph) {
this.graphManager.clear(); this.graphManager.clear();
this.graphPollingEnabled = true; // Reset polling when starting fresh
} }
console.log(`Scan started for ${target} with depth ${maxDepth}`); console.log(`Scan started for ${target} with depth ${maxDepth}`);
// Start polling immediately with faster interval for responsiveness // Start optimized polling
this.startPolling(1000); this.startOptimizedPolling();
// Force an immediate status update // Force an immediate status update
console.log('Forcing immediate status update...'); console.log('Forcing immediate status update...');
setTimeout(() => { setTimeout(() => {
this.updateStatus(); this.updateStatus();
this.updateGraph(); if (this.graphPollingEnabled) {
this.updateGraph();
}
}, 100); }, 100);
} else { } else {
@ -348,6 +401,35 @@ class DNSReconApp {
this.setUIState('idle'); this.setUIState('idle');
} }
} }
/**
* Start optimized polling with separate status and graph intervals
*/
startOptimizedPolling() {
console.log('=== STARTING OPTIMIZED POLLING ===');
this.stopPolling(); // Stop any existing polling
// Always poll status for progress bar
this.statusPollInterval = setInterval(() => {
this.updateStatus();
this.loadProviders();
}, 2000);
// Only poll graph if enabled
if (this.graphPollingEnabled) {
this.graphPollInterval = setInterval(() => {
this.updateGraph();
}, 2000);
console.log('Graph polling started');
} else {
console.log('Graph polling disabled due to node count');
}
this.updateManualRefreshButton();
console.log(`Optimized polling started - Status: enabled, Graph: ${this.graphPollingEnabled ? 'enabled' : 'disabled'}`);
}
/** /**
* Scan stop with immediate UI feedback * Scan stop with immediate UI feedback
*/ */
@ -374,15 +456,8 @@ class DNSReconApp {
this.updateStatus(); this.updateStatus();
}, 100); }, 100);
// Continue polling for a bit to catch the status change // Continue status polling for a bit to catch the status change
this.startPolling(500); // Fast polling to catch status change // No need to resume graph polling
// Stop fast polling after 10 seconds
setTimeout(() => {
if (this.scanStatus === 'stopped' || this.scanStatus === 'idle') {
this.stopPolling();
}
}, 10000);
} else { } else {
throw new Error(response.error || 'Failed to stop scan'); throw new Error(response.error || 'Failed to stop scan');
@ -458,7 +533,7 @@ class DNSReconApp {
// Get the filename from headers or create one // Get the filename from headers or create one
const contentDisposition = response.headers.get('content-disposition'); const contentDisposition = response.headers.get('content-disposition');
let filename = 'dnsrecon_export.json'; let filename = 'DNScope_export.json';
if (contentDisposition) { if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (filenameMatch) { if (filenameMatch) {
@ -573,10 +648,18 @@ class DNSReconApp {
*/ */
stopPolling() { stopPolling() {
console.log('=== STOPPING POLLING ==='); console.log('=== STOPPING POLLING ===');
if (this.pollInterval) {
clearInterval(this.pollInterval); if (this.statusPollInterval) {
this.pollInterval = null; clearInterval(this.statusPollInterval);
this.statusPollInterval = null;
} }
if (this.graphPollInterval) {
clearInterval(this.graphPollInterval);
this.graphPollInterval = null;
}
this.updateManualRefreshButton();
} }
/** /**
@ -611,7 +694,7 @@ class DNSReconApp {
} }
/** /**
* Update graph from server * Update graph from server with polling optimization
*/ */
async updateGraph() { async updateGraph() {
try { try {
@ -626,11 +709,29 @@ class DNSReconApp {
console.log('- Nodes:', graphData.nodes ? graphData.nodes.length : 0); console.log('- Nodes:', graphData.nodes ? graphData.nodes.length : 0);
console.log('- Edges:', graphData.edges ? graphData.edges.length : 0); console.log('- Edges:', graphData.edges ? graphData.edges.length : 0);
// FIXED: Always update graph, even if empty - let GraphManager handle placeholder // Always update graph, even if empty - let GraphManager handle placeholder
if (this.graphManager) { if (this.graphManager) {
this.graphManager.updateGraph(graphData); this.graphManager.updateGraph(graphData);
this.lastGraphUpdate = Date.now(); this.lastGraphUpdate = Date.now();
// Check if we should disable graph polling after this update
const nodeCount = graphData.nodes ? graphData.nodes.length : 0;
const shouldEnablePolling = nodeCount <= this.graphPollingNodeThreshold;
if (this.graphPollingEnabled && !shouldEnablePolling) {
console.log(`Graph polling disabled: ${nodeCount} nodes exceeds threshold of ${this.graphPollingNodeThreshold}`);
this.graphPollingEnabled = false;
this.showWarning(`Graph auto-refresh disabled: ${nodeCount} nodes exceed threshold of ${this.graphPollingNodeThreshold}. Use manual refresh button.`);
// Stop graph polling but keep status polling
if (this.graphPollInterval) {
clearInterval(this.graphPollInterval);
this.graphPollInterval = null;
}
this.updateManualRefreshButton();
}
// Update relationship count in status // Update relationship count in status
const edgeCount = graphData.edges ? graphData.edges.length : 0; const edgeCount = graphData.edges ? graphData.edges.length : 0;
if (this.elements.relationshipsDisplay) { if (this.elements.relationshipsDisplay) {
@ -639,7 +740,7 @@ class DNSReconApp {
} }
} else { } else {
console.error('Graph update failed:', response); console.error('Graph update failed:', response);
// FIXED: Show placeholder when graph update fails // Show placeholder when graph update fails
if (this.graphManager && this.graphManager.container) { if (this.graphManager && this.graphManager.container) {
const placeholder = this.graphManager.container.querySelector('.graph-placeholder'); const placeholder = this.graphManager.container.querySelector('.graph-placeholder');
if (placeholder) { if (placeholder) {
@ -650,7 +751,7 @@ class DNSReconApp {
} catch (error) { } catch (error) {
console.error('Failed to update graph:', error); console.error('Failed to update graph:', error);
// FIXED: Show placeholder on error // Show placeholder on error
if (this.graphManager && this.graphManager.container) { if (this.graphManager && this.graphManager.container) {
const placeholder = this.graphManager.container.querySelector('.graph-placeholder'); const placeholder = this.graphManager.container.querySelector('.graph-placeholder');
if (placeholder) { if (placeholder) {
@ -660,7 +761,6 @@ class DNSReconApp {
} }
} }
/** /**
* Update status display elements * Update status display elements
* @param {Object} status - Status object from server * @param {Object} status - Status object from server
@ -737,8 +837,6 @@ class DNSReconApp {
case 'running': case 'running':
this.setUIState('scanning', task_queue_size); this.setUIState('scanning', task_queue_size);
this.showSuccess('Scan is running'); this.showSuccess('Scan is running');
// Increase polling frequency for active scans
this.startPolling(1000); // Poll every 1 second for running scans
this.updateConnectionStatus('active'); this.updateConnectionStatus('active');
break; break;
@ -748,9 +846,10 @@ class DNSReconApp {
this.showSuccess('Scan completed successfully'); this.showSuccess('Scan completed successfully');
this.updateConnectionStatus('completed'); this.updateConnectionStatus('completed');
this.loadProviders(); this.loadProviders();
// Force a final graph update
console.log('Scan completed - forcing final graph update'); // Do final graph update when scan completes
setTimeout(() => this.updateGraph(), 100); console.log('Scan completed - performing final graph update');
setTimeout(() => this.updateGraph(), 1000);
break; break;
case 'failed': case 'failed':
@ -820,6 +919,7 @@ class DNSReconApp {
switch (state) { switch (state) {
case 'scanning': case 'scanning':
case 'running':
this.isScanning = true; this.isScanning = true;
if (this.graphManager) { if (this.graphManager) {
this.graphManager.isScanning = true; this.graphManager.isScanning = true;
@ -852,12 +952,12 @@ class DNSReconApp {
this.graphManager.isScanning = false; this.graphManager.isScanning = false;
} }
if (this.elements.startScan) { if (this.elements.startScan) {
this.elements.startScan.disabled = !isQueueEmpty; this.elements.startScan.disabled = false;
this.elements.startScan.classList.remove('loading'); this.elements.startScan.classList.remove('loading');
this.elements.startScan.innerHTML = '<span class="btn-icon">[RUN]</span><span>Start Reconnaissance</span>'; this.elements.startScan.innerHTML = '<span class="btn-icon">[RUN]</span><span>Start Reconnaissance</span>';
} }
if (this.elements.addToGraph) { if (this.elements.addToGraph) {
this.elements.addToGraph.disabled = !isQueueEmpty; this.elements.addToGraph.disabled = false;
this.elements.addToGraph.classList.remove('loading'); this.elements.addToGraph.classList.remove('loading');
} }
if (this.elements.stopScan) { if (this.elements.stopScan) {
@ -867,6 +967,9 @@ class DNSReconApp {
if (this.elements.targetInput) this.elements.targetInput.disabled = false; if (this.elements.targetInput) this.elements.targetInput.disabled = false;
if (this.elements.maxDepth) this.elements.maxDepth.disabled = false; if (this.elements.maxDepth) this.elements.maxDepth.disabled = false;
if (this.elements.configureSettings) this.elements.configureSettings.disabled = false; if (this.elements.configureSettings) this.elements.configureSettings.disabled = false;
// Update manual refresh button visibility
this.updateManualRefreshButton();
break; break;
} }
} }
@ -1613,17 +1716,9 @@ class DNSReconApp {
return groups; return groups;
} }
formatEdgeLabel(relationshipType, confidence) {
if (!relationshipType) return '';
const confidenceText = confidence >= 0.8 ? '●' : confidence >= 0.6 ? '◐' : '○';
return `${relationshipType} ${confidenceText}`;
}
createEdgeTooltip(edge) { createEdgeTooltip(edge) {
let tooltip = `<div style="font-family: 'Roboto Mono', monospace; font-size: 11px;">`; let tooltip = `<div style="font-family: 'Roboto Mono', monospace; font-size: 11px;">`;
tooltip += `<div style="color: #00ff41; font-weight: bold; margin-bottom: 4px;">${edge.label || 'Relationship'}</div>`; tooltip += `<div style="color: #00ff41; font-weight: bold; margin-bottom: 4px;">${edge.label || 'Relationship'}</div>`;
tooltip += `<div style="color: #999; margin-bottom: 2px;">Confidence: ${(edge.confidence_score * 100).toFixed(1)}%</div>`;
// UPDATED: Use raw provider name (no formatting) // UPDATED: Use raw provider name (no formatting)
if (edge.source_provider) { if (edge.source_provider) {
@ -1763,7 +1858,7 @@ class DNSReconApp {
html += ` html += `
<div class="relationship-compact-item"> <div class="relationship-compact-item">
<span class="node-link-compact" data-node-id="${innerNodeId}">${innerNodeId}</span> <span class="node-link-compact" data-node-id="${innerNodeId}">${innerNodeId}</span>
<button class="btn-icon-small extract-node-btn" <button class="graph-control-btn extract-node-btn"
title="Extract to graph" title="Extract to graph"
data-large-entity-id="${largeEntityId}" data-large-entity-id="${largeEntityId}"
data-node-id="${innerNodeId}">[+]</button> data-node-id="${innerNodeId}">[+]</button>
@ -1790,8 +1885,6 @@ class DNSReconApp {
`; `;
node.incoming_edges.forEach(edge => { node.incoming_edges.forEach(edge => {
const confidence = edge.data.confidence_score || 0;
const confidenceClass = confidence >= 0.8 ? 'high' : confidence >= 0.6 ? 'medium' : 'low';
html += ` html += `
<div class="relationship-item"> <div class="relationship-item">
@ -1800,9 +1893,6 @@ class DNSReconApp {
</div> </div>
<div class="relationship-type"> <div class="relationship-type">
<span class="relation-label">${edge.data.relationship_type}</span> <span class="relation-label">${edge.data.relationship_type}</span>
<span class="confidence-indicator confidence-${confidenceClass}" title="Confidence: ${(confidence * 100).toFixed(1)}%">
${'●'.repeat(Math.ceil(confidence * 3))}
</span>
</div> </div>
</div> </div>
`; `;
@ -1821,9 +1911,6 @@ class DNSReconApp {
`; `;
node.outgoing_edges.forEach(edge => { node.outgoing_edges.forEach(edge => {
const confidence = edge.data.confidence_score || 0;
const confidenceClass = confidence >= 0.8 ? 'high' : confidence >= 0.6 ? 'medium' : 'low';
html += ` html += `
<div class="relationship-item"> <div class="relationship-item">
<div class="relationship-target node-link" data-node-id="${edge.to}"> <div class="relationship-target node-link" data-node-id="${edge.to}">
@ -1831,9 +1918,6 @@ class DNSReconApp {
</div> </div>
<div class="relationship-type"> <div class="relationship-type">
<span class="relation-label">${edge.data.relationship_type}</span> <span class="relation-label">${edge.data.relationship_type}</span>
<span class="confidence-indicator confidence-${confidenceClass}" title="Confidence: ${(confidence * 100).toFixed(1)}%">
${'●'.repeat(Math.ceil(confidence * 3))}
</span>
</div> </div>
</div> </div>
`; `;
@ -2023,6 +2107,16 @@ class DNSReconApp {
async extractNode(largeEntityId, nodeId) { async extractNode(largeEntityId, nodeId) {
try { try {
console.log(`Extracting node ${nodeId} from large entity ${largeEntityId}`);
// Show immediate feedback
const button = document.querySelector(`[data-node-id="${nodeId}"][data-large-entity-id="${largeEntityId}"]`);
if (button) {
const originalContent = button.innerHTML;
button.innerHTML = '[...]';
button.disabled = true;
}
const response = await this.apiCall('/api/graph/large-entity/extract', 'POST', { const response = await this.apiCall('/api/graph/large-entity/extract', 'POST', {
large_entity_id: largeEntityId, large_entity_id: largeEntityId,
node_id: nodeId, node_id: nodeId,
@ -2031,13 +2125,32 @@ class DNSReconApp {
if (response.success) { if (response.success) {
this.showSuccess(response.message); this.showSuccess(response.message);
// If the scanner was idle, it's now running. Start polling to see the new node appear. // FIXED: Don't update local modal data - let backend be source of truth
if (this.scanStatus === 'idle') { // Force immediate graph update to get fresh backend data
this.startPolling(1000); console.log('Extraction successful, updating graph with fresh backend data');
} else { await this.updateGraph();
// If already scanning, force a quick graph update to see the change sooner.
setTimeout(() => this.updateGraph(), 500); // FIXED: Re-fetch graph data instead of manipulating local state
} setTimeout(async () => {
try {
const graphResponse = await this.apiCall('/api/graph');
if (graphResponse.success) {
this.graphManager.updateGraph(graphResponse.graph);
// Update modal with fresh data if still open
if (this.elements.nodeModal && this.elements.nodeModal.style.display === 'block') {
if (this.graphManager.nodes) {
const updatedLargeEntity = this.graphManager.nodes.get(largeEntityId);
if (updatedLargeEntity) {
this.showNodeModal(updatedLargeEntity);
}
}
}
}
} catch (error) {
console.error('Error refreshing graph after extraction:', error);
}
}, 100);
} else { } else {
throw new Error(response.error || 'Extraction failed on the server.'); throw new Error(response.error || 'Extraction failed on the server.');
@ -2045,6 +2158,13 @@ class DNSReconApp {
} catch (error) { } catch (error) {
console.error('Failed to extract node:', error); console.error('Failed to extract node:', error);
this.showError(`Extraction failed: ${error.message}`); this.showError(`Extraction failed: ${error.message}`);
// Restore button state on error
const button = document.querySelector(`[data-node-id="${nodeId}"][data-large-entity-id="${largeEntityId}"]`);
if (button) {
button.innerHTML = '[+]';
button.disabled = false;
}
} }
} }
@ -2217,51 +2337,6 @@ class DNSReconApp {
} }
} }
/**
* Save API Keys
*/
async saveApiKeys() {
const inputs = this.elements.apiKeyInputs.querySelectorAll('input');
const keys = {};
inputs.forEach(input => {
const provider = input.dataset.provider;
const value = input.value.trim();
if (provider && value) {
keys[provider] = value;
}
});
if (Object.keys(keys).length === 0) {
this.showWarning('No API keys were entered.');
return;
}
try {
const response = await this.apiCall('/api/config/api-keys', 'POST', keys);
if (response.success) {
this.showSuccess(response.message);
this.hideSettingsModal();
this.loadProviders(); // Refresh provider status
} else {
throw new Error(response.error || 'Failed to save API keys');
}
} catch (error) {
this.showError(`Error saving API keys: ${error.message}`);
}
}
/**
* Reset API Key fields
*/
resetApiKeys() {
const inputs = this.elements.apiKeyInputs.querySelectorAll('input');
inputs.forEach(input => {
input.value = '';
});
}
/** /**
* Make API call to server * Make API call to server
* @param {string} endpoint - API endpoint * @param {string} endpoint - API endpoint
@ -2644,5 +2719,5 @@ style.textContent = `
document.head.appendChild(style); document.head.appendChild(style);
// Initialize application when page loads // Initialize application when page loads
console.log('Creating DNSReconApp instance...'); console.log('Creating DNScopeApp instance...');
const app = new DNSReconApp(); const app = new DNScopeApp();

View File

@ -4,7 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DNSRecon - Infrastructure Reconnaissance</title> <title>DNScope - Infrastructure Reconnaissance</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.css" rel="stylesheet" type="text/css"> <link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.css" rel="stylesheet" type="text/css">
@ -18,8 +18,8 @@
<header class="header"> <header class="header">
<div class="header-content"> <div class="header-content">
<div class="logo"> <div class="logo">
<span class="logo-icon">[DNS]</span> <span class="logo-icon">[DN]</span>
<span class="logo-text">RECON</span> <span class="logo-text">Scope</span>
</div> </div>
<div class="status-indicator"> <div class="status-indicator">
<span id="connection-status" class="status-dot"></span> <span id="connection-status" class="status-dot"></span>

View File

@ -1,7 +1,7 @@
# dnsrecon-reduced/utils/__init__.py # DNScope-reduced/utils/__init__.py
""" """
Utility modules for DNSRecon. Utility modules for DNScope.
Contains helper functions, export management, and supporting utilities. Contains helper functions, export management, and supporting utilities.
""" """

View File

@ -1,7 +1,7 @@
# dnsrecon-reduced/utils/export_manager.py # DNScope-reduced/utils/export_manager.py
""" """
Centralized export functionality for DNSRecon. Centralized export functionality for DNScope.
Handles all data export operations with forensic integrity and proper formatting. Handles all data export operations with forensic integrity and proper formatting.
ENHANCED: Professional forensic executive summary generation for court-ready documentation. ENHANCED: Professional forensic executive summary generation for court-ready documentation.
""" """
@ -18,7 +18,7 @@ from utils.helpers import _is_valid_domain, _is_valid_ip
class ExportManager: class ExportManager:
""" """
Centralized manager for all DNSRecon export operations. Centralized manager for all DNScope export operations.
Maintains forensic integrity and provides consistent export formats. Maintains forensic integrity and provides consistent export formats.
ENHANCED: Advanced forensic analysis and professional reporting capabilities. ENHANCED: Advanced forensic analysis and professional reporting capabilities.
""" """
@ -188,7 +188,6 @@ class ExportManager:
f" - Type: {domain_info['classification']}", f" - Type: {domain_info['classification']}",
f" - Connected IPs: {len(domain_info['ips'])}", f" - Connected IPs: {len(domain_info['ips'])}",
f" - Certificate Status: {domain_info['cert_status']}", f" - Certificate Status: {domain_info['cert_status']}",
f" - Relationship Confidence: {domain_info['avg_confidence']:.2f}",
]) ])
if domain_info['security_notes']: if domain_info['security_notes']:
@ -247,11 +246,9 @@ class ExportManager:
]) ])
for rel in key_relationships[:8]: # Top 8 relationships for rel in key_relationships[:8]: # Top 8 relationships
confidence_desc = self._describe_confidence(rel['confidence'])
report.extend([ report.extend([
f"{rel['source']}{rel['target']}", f"{rel['source']}{rel['target']}",
f" - Relationship: {self._humanize_relationship_type(rel['type'])}", f" - Relationship: {self._humanize_relationship_type(rel['type'])}",
f" - Evidence Strength: {confidence_desc} ({rel['confidence']:.2f})",
f" - Discovery Method: {rel['provider']}", f" - Discovery Method: {rel['provider']}",
"" ""
]) ])
@ -291,21 +288,15 @@ class ExportManager:
"Data Quality Assessment:", "Data Quality Assessment:",
f"• Total API Requests: {audit_trail.get('session_metadata', {}).get('total_requests', 0)}", f"• Total API Requests: {audit_trail.get('session_metadata', {}).get('total_requests', 0)}",
f"• Data Providers Used: {len(audit_trail.get('session_metadata', {}).get('providers_used', []))}", f"• Data Providers Used: {len(audit_trail.get('session_metadata', {}).get('providers_used', []))}",
f"• Relationship Confidence Distribution:",
]) ])
# Confidence distribution correlation_provider = next((p for p in scanner.providers if p.get_name() == 'correlation'), None)
confidence_dist = self._calculate_confidence_distribution(edges) correlation_count = len(correlation_provider.correlation_index) if correlation_provider else 0
for level, count in confidence_dist.items():
percentage = (count / len(edges) * 100) if edges else 0
report.extend([
f" - {level.title()} Confidence (≥{self._get_confidence_threshold(level)}): {count} ({percentage:.1f}%)",
])
report.extend([ report.extend([
"", "",
"Correlation Analysis:", "Correlation Analysis:",
f"• Entity Correlations Identified: {len(scanner.graph.correlation_index)}", f"• Entity Correlations Identified: {correlation_count}",
f"• Cross-Reference Validation: {self._count_cross_validated_relationships(edges)} relationships verified by multiple sources", f"• Cross-Reference Validation: {self._count_cross_validated_relationships(edges)} relationships verified by multiple sources",
"" ""
]) ])
@ -324,7 +315,7 @@ class ExportManager:
"a complete audit trail maintained for forensic integrity.", "a complete audit trail maintained for forensic integrity.",
"", "",
f"Investigation completed: {now}", f"Investigation completed: {now}",
f"Report authenticated by: DNSRecon v{self._get_version()}", f"Report authenticated by: DNScope v{self._get_version()}",
"", "",
"=" * 80, "=" * 80,
"END OF REPORT", "END OF REPORT",
@ -375,9 +366,7 @@ class ExportManager:
if len(connected_ips) > 5: if len(connected_ips) > 5:
security_notes.append("Multiple IP endpoints") security_notes.append("Multiple IP endpoints")
# Average confidence
domain_edges = [e for e in edges if e['from'] == domain['id']] domain_edges = [e for e in edges if e['from'] == domain['id']]
avg_confidence = sum(e['confidence_score'] for e in domain_edges) / len(domain_edges) if domain_edges else 0
domain_analysis.append({ domain_analysis.append({
'domain': domain['id'], 'domain': domain['id'],
@ -385,7 +374,6 @@ class ExportManager:
'ips': connected_ips, 'ips': connected_ips,
'cert_status': cert_status, 'cert_status': cert_status,
'security_notes': security_notes, 'security_notes': security_notes,
'avg_confidence': avg_confidence
}) })
# Sort by number of connections (most connected first) # Sort by number of connections (most connected first)
@ -480,7 +468,7 @@ class ExportManager:
def _identify_key_relationships(self, edges: List[Dict]) -> List[Dict[str, Any]]: def _identify_key_relationships(self, edges: List[Dict]) -> List[Dict[str, Any]]:
"""Identify the most significant relationships in the infrastructure.""" """Identify the most significant relationships in the infrastructure."""
# Score relationships by confidence and type importance # Score relationships by type importance
relationship_importance = { relationship_importance = {
'dns_a_record': 0.9, 'dns_a_record': 0.9,
'dns_aaaa_record': 0.9, 'dns_aaaa_record': 0.9,
@ -491,23 +479,19 @@ class ExportManager:
'dns_ns_record': 0.7 'dns_ns_record': 0.7
} }
scored_edges = [] edges = []
for edge in edges: for edge in edges:
base_confidence = edge.get('confidence_score', 0)
type_weight = relationship_importance.get(edge.get('label', ''), 0.5) type_weight = relationship_importance.get(edge.get('label', ''), 0.5)
combined_score = (base_confidence * 0.7) + (type_weight * 0.3)
scored_edges.append({ edges.append({
'source': edge['from'], 'source': edge['from'],
'target': edge['to'], 'target': edge['to'],
'type': edge.get('label', ''), 'type': edge.get('label', ''),
'confidence': base_confidence,
'provider': edge.get('source_provider', ''), 'provider': edge.get('source_provider', ''),
'score': combined_score
}) })
# Return top relationships by score # Return top relationships by score
return sorted(scored_edges, key=lambda x: x['score'], reverse=True) return sorted(edges, key=lambda x: x['score'], reverse=True)
def _analyze_certificate_infrastructure(self, nodes: List[Dict]) -> Dict[str, Any]: def _analyze_certificate_infrastructure(self, nodes: List[Dict]) -> Dict[str, Any]:
"""Analyze certificate infrastructure across all domains.""" """Analyze certificate infrastructure across all domains."""
@ -570,19 +554,6 @@ class ExportManager:
else: else:
return "Mixed Status" return "Mixed Status"
def _describe_confidence(self, confidence: float) -> str:
"""Convert confidence score to descriptive text."""
if confidence >= 0.9:
return "Very High"
elif confidence >= 0.8:
return "High"
elif confidence >= 0.6:
return "Medium"
elif confidence >= 0.4:
return "Low"
else:
return "Very Low"
def _humanize_relationship_type(self, rel_type: str) -> str: def _humanize_relationship_type(self, rel_type: str) -> str:
"""Convert technical relationship types to human-readable descriptions.""" """Convert technical relationship types to human-readable descriptions."""
type_map = { type_map = {
@ -599,26 +570,6 @@ class ExportManager:
} }
return type_map.get(rel_type, rel_type.replace('_', ' ').title()) return type_map.get(rel_type, rel_type.replace('_', ' ').title())
def _calculate_confidence_distribution(self, edges: List[Dict]) -> Dict[str, int]:
"""Calculate confidence score distribution."""
distribution = {'high': 0, 'medium': 0, 'low': 0}
for edge in edges:
confidence = edge.get('confidence_score', 0)
if confidence >= 0.8:
distribution['high'] += 1
elif confidence >= 0.6:
distribution['medium'] += 1
else:
distribution['low'] += 1
return distribution
def _get_confidence_threshold(self, level: str) -> str:
"""Get confidence threshold for a level."""
thresholds = {'high': '0.80', 'medium': '0.60', 'low': '0.00'}
return thresholds.get(level, '0.00')
def _count_cross_validated_relationships(self, edges: List[Dict]) -> int: def _count_cross_validated_relationships(self, edges: List[Dict]) -> int:
"""Count relationships verified by multiple providers.""" """Count relationships verified by multiple providers."""
# Group edges by source-target pair # Group edges by source-target pair
@ -694,7 +645,7 @@ class ExportManager:
if centrality >= threshold] if centrality >= threshold]
def _get_version(self) -> str: def _get_version(self) -> str:
"""Get DNSRecon version for report authentication.""" """Get DNScope version for report authentication."""
return "1.0.0-forensic" return "1.0.0-forensic"
def export_graph_json(self, graph_manager) -> Dict[str, Any]: def export_graph_json(self, graph_manager) -> Dict[str, Any]:
@ -717,7 +668,7 @@ class ExportManager:
'last_modified': graph_manager.last_modified, 'last_modified': graph_manager.last_modified,
'total_nodes': graph_manager.get_node_count(), 'total_nodes': graph_manager.get_node_count(),
'total_edges': graph_manager.get_edge_count(), 'total_edges': graph_manager.get_edge_count(),
'graph_format': 'dnsrecon_v1_unified_model' 'graph_format': 'DNScope_v1_unified_model'
}, },
'graph': graph_data, 'graph': graph_data,
'statistics': graph_manager.get_statistics() 'statistics': graph_manager.get_statistics()
@ -818,7 +769,7 @@ class ExportManager:
} }
extension = extension_map.get(export_type, 'txt') extension = extension_map.get(export_type, 'txt')
return f"dnsrecon_{export_type}_{safe_target}_{timestamp_str}.{extension}" return f"DNScope_{export_type}_{safe_target}_{timestamp_str}.{extension}"
class CustomJSONEncoder(json.JSONEncoder): class CustomJSONEncoder(json.JSONEncoder):

View File

@ -1,4 +1,4 @@
# dnsrecon-reduced/utils/helpers.py # DNScope-reduced/utils/helpers.py
import ipaddress import ipaddress
from typing import Union from typing import Union