1 Commits

Author SHA1 Message Date
overcuriousity
9f3b17e658 try_db 2025-09-14 22:54:37 +02:00
23 changed files with 2259 additions and 4902 deletions

View File

@@ -29,6 +29,6 @@ MAX_CONCURRENT_REQUESTS=5
# The number of results from a provider that triggers the "large entity" grouping. # The number of results from a provider that triggers the "large entity" grouping.
LARGE_ENTITY_THRESHOLD=100 LARGE_ENTITY_THRESHOLD=100
# The number of times to retry a target if a provider fails. # The number of times to retry a target if a provider fails.
MAX_RETRIES_PER_TARGET=8 MAX_RETRIES_PER_TARGET=3
# How long cached provider responses are stored (in hours). # How long cached provider responses are stored (in hours).
CACHE_EXPIRY_HOURS=12 CACHE_EXPIRY_HOURS=12

1
.gitignore vendored
View File

@@ -169,4 +169,3 @@ cython_debug/
#.idea/ #.idea/
dump.rdb dump.rdb
cache/

226
README.md
View File

@@ -4,32 +4,28 @@ DNSRecon is an interactive, passive reconnaissance tool designed to map adversar
**Current Status: Phase 2 Implementation** **Current Status: Phase 2 Implementation**
* ✅ Core infrastructure and graph engine - ✅ Core infrastructure and graph engine
* ✅ Multi-provider support (crt.sh, DNS, Shodan) - ✅ Multi-provider support (crt.sh, DNS, Shodan)
* ✅ Session-based multi-user support - ✅ Session-based multi-user support
* ✅ Real-time web interface with interactive visualization - ✅ Real-time web interface with interactive visualization
* ✅ Forensic logging system and JSON export - ✅ Forensic logging system and JSON export
-----
## Features ## Features
* **Passive Reconnaissance**: Gathers data without direct contact with target infrastructure. - **Passive Reconnaissance**: Gathers data without direct contact with target infrastructure.
* **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. - **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.
-----
## Installation ## Installation
### Prerequisites ### Prerequisites
* Python 3.8 or higher - Python 3.8 or higher
* A modern web browser with JavaScript enabled - A modern web browser with JavaScript enabled
* (Recommended) A Linux host for running the application and the optional DNS cache. - (Recommended) A Linux host for running the application and the optional DNS cache.
### 1\. Clone the Project ### 1\. Clone the Project
@@ -48,50 +44,156 @@ source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
``` ```
The `requirements.txt` file contains the following dependencies: ### 3\. (Optional but Recommended) Set up a Local DNS Caching Resolver
* Flask\>=2.3.3 Running a local DNS caching resolver can significantly speed up DNS queries and reduce your network footprint. Heres how to set up `unbound` on a Debian-based Linux distribution (like Ubuntu).
* networkx\>=3.1
* requests\>=2.31.0
* python-dateutil\>=2.8.2
* Werkzeug\>=2.3.7
* urllib3\>=2.0.0
* dnspython\>=2.4.2
* gunicorn
* redis
* python-dotenv
----- **a. Install Unbound:**
## Configuration
DNSRecon 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 sudo apt update
sudo apt install unbound -y
``` ```
The following environment variables are available for configuration: **b. Configure Unbound:**
Create a new configuration file for DNSRecon:
| Variable | Description | Default | ```bash
| :--- | :--- | :--- | sudo nano /etc/unbound/unbound.conf.d/dnsrecon.conf
| `SHODAN_API_KEY` | Your Shodan API key. | | ```
| `FLASK_SECRET_KEY`| A strong, random secret key for session security. | `your-very-secret-and-random-key-here` |
| `FLASK_HOST` | The host address for the Flask application. | `127.0.0.1` |
| `FLASK_PORT` | The port for the Flask application. | `5000` |
| `FLASK_DEBUG` | Enable or disable Flask's debug mode. | `True` |
| `FLASK_PERMANENT_SESSION_LIFETIME_HOURS`| How long a user's session in the browser lasts (in hours). | `2` |
| `SESSION_TIMEOUT_MINUTES` | How long inactive scanner data is stored in Redis (in minutes). | `60` |
| `DEFAULT_RECURSION_DEPTH` | The default number of levels to recurse when scanning. | `2` |
| `DEFAULT_TIMEOUT` | Default timeout for provider API requests in seconds. | `30` |
| `MAX_CONCURRENT_REQUESTS`| The number of concurrent provider requests to make. | `5` |
| `LARGE_ENTITY_THRESHOLD`| The number of results from a provider that triggers the "large entity" grouping. | `100` |
| `MAX_RETRIES_PER_TARGET`| The number of times to retry a target if a provider fails. | `8` |
| `CACHE_EXPIRY_HOURS`| How long cached provider responses are stored (in hours). | `12` |
----- Add the following content to the file:
## Systemd Service ```
server:
# Listen on localhost for all users
interface: 127.0.0.1
access-control: 0.0.0.0/0 refuse
access-control: 127.0.0.0/8 allow
# Enable prefetching of popular items
prefetch: yes
```
**c. Restart Unbound and set it as the default resolver:**
```bash
sudo systemctl restart unbound
sudo systemctl enable unbound
```
To use this resolver for your system, you may need to update your network settings to point to `127.0.0.1` as your DNS server.
**d. Update DNSProvider to use the local resolver:**
In `dnsrecon/providers/dns_provider.py`, you can explicitly set the resolver's nameservers in the `__init__` method:
```python
# dnsrecon/providers/dns_provider.py
class DNSProvider(BaseProvider):
def __init__(self, session_config=None):
"""Initialize DNS provider with session-specific configuration."""
super().__init__(...)
# Configure DNS resolver
self.resolver = dns.resolver.Resolver()
self.resolver.nameservers = ['127.0.0.1'] # Use local caching resolver
self.resolver.timeout = 5
self.resolver.lifetime = 10
```
## Usage (Development)
### 1\. Start the Application
```bash
python app.py
```
### 2\. Open Your Browser
Navigate to `http://127.0.0.1:5000`.
### 3\. Basic Reconnaissance Workflow
1. **Enter Target Domain**: Input a domain like `example.com`.
2. **Select Recursion Depth**: Depth 2 is recommended for most investigations.
3. **Start Reconnaissance**: Click "Start Reconnaissance" to begin.
4. **Monitor Progress**: Watch the real-time graph build as relationships are discovered.
5. **Analyze and Export**: Interact with the graph and download the results when the scan is complete.
## Production Deployment
To deploy DNSRecon in a production environment, follow these steps:
### 1\. Use a Production WSGI Server
Do not use the built-in Flask development server for production. Use a WSGI server like **Gunicorn**:
```bash
pip install gunicorn
gunicorn --workers 4 --bind 0.0.0.0:5000 app:app
```
### 2\. Configure Environment Variables
Set the following environment variables for a secure and configurable deployment:
```bash
# Generate a strong, random secret key
export SECRET_KEY='your-super-secret-and-random-key'
# Set Flask to production mode
export FLASK_ENV='production'
export FLASK_DEBUG=False
# API keys (optional, but recommended for full functionality)
export SHODAN_API_KEY="your_shodan_key"
```
### 3\. Use a Reverse Proxy
Set up a reverse proxy like **Nginx** to sit in front of the Gunicorn server. This provides several benefits, including:
- **TLS/SSL Termination**: Securely handle HTTPS traffic.
- **Load Balancing**: Distribute traffic across multiple application instances.
- **Serving Static Files**: Efficiently serve CSS and JavaScript files.
**Example Nginx Configuration:**
```nginx
server {
listen 80;
server_name your_domain.com;
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name your_domain.com;
# SSL cert configuration
ssl_certificate /etc/letsencrypt/live/your_domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your_domain.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /static {
alias /path/to/your/dnsrecon/static;
expires 30d;
}
}
```
## Autostart with systemd
To run DNSRecon as a service that starts automatically on boot, you can use `systemd`. To run DNSRecon as a service that starts automatically on boot, you can use `systemd`.
@@ -143,18 +245,12 @@ You can check the status of the service at any time with:
sudo systemctl status dnsrecon.service sudo systemctl status dnsrecon.service
``` ```
----- ## Security Considerations
- **API Keys**: API keys are stored in memory for the duration of a user session and are not written to disk.
- **Rate Limiting**: DNSRecon includes built-in rate limiting to be respectful to data sources.
- **Local Use**: The application is designed for local or trusted network use and does not have built-in authentication. **Do not expose it directly to the internet without proper security controls.**
## License ## License
This project is licensed under the terms of the **BSD-3-Clause** license. This project is licensed under the terms of the license agreement found in the `LICENSE` file.
Copyright (c) 2025 mstoeck3.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

128
app.py
View File

@@ -13,8 +13,6 @@ import io
from core.session_manager import session_manager from core.session_manager import session_manager
from config import config from config import config
from core.graph_manager import NodeType
from utils.helpers import is_valid_target
app = Flask(__name__) app = Flask(__name__)
@@ -66,21 +64,18 @@ def start_scan():
try: try:
data = request.get_json() data = request.get_json()
if not data or 'target' not in data: if not data or 'target_domain' not in data:
return jsonify({'success': False, 'error': 'Missing target in request'}), 400 return jsonify({'success': False, 'error': 'Missing target_domain in request'}), 400
target = data['target'].strip() target_domain = data['target_domain'].strip()
max_depth = data.get('max_depth', config.default_recursion_depth) max_depth = data.get('max_depth', config.default_recursion_depth)
clear_graph = data.get('clear_graph', True) clear_graph = data.get('clear_graph', True)
force_rescan_target = data.get('force_rescan_target', None) # **FIX**: Get the new parameter
print(f"Parsed - target: '{target}', max_depth: {max_depth}, clear_graph: {clear_graph}, force_rescan: {force_rescan_target}") print(f"Parsed - target_domain: '{target_domain}', max_depth: {max_depth}, clear_graph: {clear_graph}")
# Validation # Validation
if not target: if not target_domain:
return jsonify({'success': False, 'error': 'Target cannot be empty'}), 400 return jsonify({'success': False, 'error': 'Target domain cannot be empty'}), 400
if not is_valid_target(target):
return jsonify({'success': False, 'error': 'Invalid target format. Please enter a valid domain or IP address.'}), 400
if not isinstance(max_depth, int) or not 1 <= max_depth <= 5: if not isinstance(max_depth, int) or not 1 <= max_depth <= 5:
return jsonify({'success': False, 'error': 'Max depth must be an integer between 1 and 5'}), 400 return jsonify({'success': False, 'error': 'Max depth must be an integer between 1 and 5'}), 400
@@ -105,7 +100,7 @@ def start_scan():
print(f"Using scanner {id(scanner)} in session {user_session_id}") print(f"Using scanner {id(scanner)} in session {user_session_id}")
success = scanner.start_scan(target, max_depth, clear_graph=clear_graph, force_rescan_target=force_rescan_target) # **FIX**: Pass the new parameter success = scanner.start_scan(target_domain, max_depth, clear_graph=clear_graph)
if success: if success:
return jsonify({ return jsonify({
@@ -124,7 +119,7 @@ def start_scan():
print(f"ERROR: Exception in start_scan endpoint: {e}") print(f"ERROR: Exception in start_scan endpoint: {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)}'}), 500
@app.route('/api/scan/stop', methods=['POST']) @app.route('/api/scan/stop', methods=['POST'])
def stop_scan(): def stop_scan():
"""Stop the current scan with immediate GUI feedback.""" """Stop the current scan with immediate GUI feedback."""
@@ -282,108 +277,6 @@ def get_graph_data():
} }
}), 500 }), 500
@app.route('/api/graph/large-entity/extract', methods=['POST'])
def extract_from_large_entity():
"""Extract a node from a large entity, making it a standalone node."""
try:
data = request.get_json()
large_entity_id = data.get('large_entity_id')
node_id = data.get('node_id')
if not large_entity_id or not node_id:
return jsonify({'success': False, 'error': 'Missing required parameters'}), 400
user_session_id, scanner = get_user_scanner()
if not scanner:
return jsonify({'success': False, 'error': 'No active session found'}), 404
success = scanner.extract_node_from_large_entity(large_entity_id, node_id)
if success:
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
except Exception as e:
print(f"ERROR: Exception in extract_from_large_entity endpoint: {e}")
traceback.print_exc()
return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
@app.route('/api/graph/node/<node_id>', methods=['DELETE'])
def delete_graph_node(node_id):
"""Delete a node from the graph for the current user session."""
try:
user_session_id, scanner = get_user_scanner()
if not scanner:
return jsonify({'success': False, 'error': 'No active session found'}), 404
success = scanner.graph.remove_node(node_id)
if success:
# Persist the change
session_manager.update_session_scanner(user_session_id, scanner)
return jsonify({'success': True, 'message': f'Node {node_id} deleted successfully.'})
else:
return jsonify({'success': False, 'error': f'Node {node_id} not found in graph.'}), 404
except Exception as e:
print(f"ERROR: Exception in delete_graph_node endpoint: {e}")
traceback.print_exc()
return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
@app.route('/api/graph/revert', methods=['POST'])
def revert_graph_action():
"""Reverts a graph action, such as re-adding a deleted node."""
try:
data = request.get_json()
if not data or 'type' not in data or 'data' not in data:
return jsonify({'success': False, 'error': 'Invalid revert request format'}), 400
user_session_id, scanner = get_user_scanner()
if not scanner:
return jsonify({'success': False, 'error': 'No active session found'}), 404
action_type = data['type']
action_data = data['data']
if action_type == 'delete':
# Re-add the node
node_to_add = action_data.get('node')
if node_to_add:
scanner.graph.add_node(
node_id=node_to_add['id'],
node_type=NodeType(node_to_add['type']),
attributes=node_to_add.get('attributes'),
description=node_to_add.get('description'),
metadata=node_to_add.get('metadata')
)
# Re-add the edges
edges_to_add = action_data.get('edges', [])
for edge in edges_to_add:
# Add edge only if both nodes exist to prevent errors
if scanner.graph.graph.has_node(edge['from']) and scanner.graph.graph.has_node(edge['to']):
scanner.graph.add_edge(
source_id=edge['from'],
target_id=edge['to'],
relationship_type=edge['metadata']['relationship_type'],
confidence_score=edge['metadata']['confidence_score'],
source_provider=edge['metadata']['source_provider'],
raw_data=edge.get('raw_data', {})
)
# Persist the change
session_manager.update_session_scanner(user_session_id, scanner)
return jsonify({'success': True, 'message': 'Delete action reverted successfully.'})
return jsonify({'success': False, 'error': f'Unknown revert action type: {action_type}'}), 400
except Exception as e:
print(f"ERROR: Exception in revert_graph_action endpoint: {e}")
traceback.print_exc()
return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
@app.route('/api/export', methods=['GET']) @app.route('/api/export', methods=['GET'])
@@ -437,10 +330,9 @@ def get_providers():
user_session_id, scanner = get_user_scanner() user_session_id, scanner = get_user_scanner()
if scanner: if scanner:
# Updated debug print to be consistent with the new progress bar logic
completed_tasks = scanner.indicators_completed completed_tasks = scanner.indicators_completed
total_tasks = scanner.total_tasks_ever_enqueued enqueued_tasks = len(scanner.task_queue)
print(f"DEBUG: Task Progress - Completed: {completed_tasks}, Total Enqueued: {total_tasks}") print(f"DEBUG: Tasks - Completed: {completed_tasks}, Enqueued: {enqueued_tasks}")
else: else:
print("DEBUG: No active scanner session found.") print("DEBUG: No active scanner session found.")

View File

@@ -1,5 +1,3 @@
# dnsrecon-reduced/config.py
""" """
Configuration management for DNSRecon tool. Configuration management for DNSRecon tool.
Handles API key storage, rate limiting, and default settings. Handles API key storage, rate limiting, and default settings.
@@ -21,10 +19,10 @@ class Config:
# --- General Settings --- # --- General Settings ---
self.default_recursion_depth = 2 self.default_recursion_depth = 2
self.default_timeout = 30 self.default_timeout = 15
self.max_concurrent_requests = 5 self.max_concurrent_requests = 5
self.large_entity_threshold = 100 self.large_entity_threshold = 100
self.max_retries_per_target = 8 self.max_retries_per_target = 3
self.cache_expiry_hours = 12 self.cache_expiry_hours = 12
# --- Provider Caching Settings --- # --- Provider Caching Settings ---
@@ -32,7 +30,7 @@ class Config:
# --- Rate Limiting (requests per minute) --- # --- Rate Limiting (requests per minute) ---
self.rate_limits = { self.rate_limits = {
'crtsh': 5, 'crtsh': 30,
'shodan': 60, 'shodan': 60,
'dns': 100 'dns': 100
} }

View File

@@ -1,10 +1,8 @@
# dnsrecon-reduced/core/graph_manager.py # core/graph_manager.py
""" """
Graph data model for DNSRecon using NetworkX. Graph data model for DNSRecon using NetworkX.
Manages in-memory graph storage with confidence scoring and forensic metadata. Manages in-memory graph storage with confidence scoring and forensic metadata.
Now fully compatible with the unified ProviderResult data model.
UPDATED: Fixed certificate styling and correlation edge labeling.
""" """
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -30,7 +28,6 @@ class GraphManager:
""" """
Thread-safe graph manager for DNSRecon infrastructure mapping. Thread-safe graph manager for DNSRecon infrastructure mapping.
Uses NetworkX for in-memory graph storage with confidence scoring. Uses NetworkX for in-memory graph storage with confidence scoring.
Compatible with unified ProviderResult data model.
""" """
def __init__(self): def __init__(self):
@@ -41,7 +38,6 @@ class GraphManager:
self.correlation_index = {} self.correlation_index = {}
# Compile regex for date filtering for efficiency # Compile regex for date filtering for efficiency
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 = ['confidence', 'provider', 'timestamp', 'type','crtsh_cert_validity_period_days']
def __getstate__(self): def __getstate__(self):
"""Prepare GraphManager for pickling, excluding compiled regex.""" """Prepare GraphManager for pickling, excluding compiled regex."""
@@ -56,115 +52,240 @@ class GraphManager:
self.__dict__.update(state) self.__dict__.update(state)
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}')
def process_correlations_for_node(self, node_id: str): def _update_correlation_index(self, node_id: str, data: Any, path: List[str] = [], parent_attr: str = ""):
""" """Recursively traverse metadata and add hashable values to the index with better path tracking."""
UPDATED: Process correlations for a given node with enhanced tracking. if path is None:
Now properly tracks which attribute/provider created each correlation. path = []
"""
if not self.graph.has_node(node_id): if isinstance(data, dict):
for key, value in data.items():
self._update_correlation_index(node_id, value, path + [key], key)
elif isinstance(data, list):
for i, item in enumerate(data):
# Instead of just using [i], include the parent attribute context
list_path_component = f"[{i}]" if not parent_attr else f"{parent_attr}[{i}]"
self._update_correlation_index(node_id, item, path + [list_path_component], parent_attr)
else:
self._add_to_correlation_index(node_id, data, ".".join(path), parent_attr)
def _add_to_correlation_index(self, node_id: str, value: Any, path_str: str, parent_attr: str = ""):
"""Add a hashable value to the correlation index, filtering out noise."""
if not isinstance(value, (str, int, float, bool)) or value is None:
return return
node_attributes = self.graph.nodes[node_id].get('attributes', []) # Ignore certain paths that contain noisy, non-unique identifiers
if any(keyword in path_str.lower() for keyword in ['count', 'total', 'timestamp', 'date']):
return
# Filter out common low-entropy values and date-like strings
if isinstance(value, str):
# FIXED: Prevent correlation on date/time strings.
if self.date_pattern.match(value):
return
if len(value) < 4 or value.lower() in ['true', 'false', 'unknown', 'none', 'crt.sh']:
return
elif isinstance(value, int) and (abs(value) < 1024 or abs(value) > 65535):
return # Ignore small integers and common port numbers
elif isinstance(value, bool):
return # Ignore boolean values
# Add the valuable correlation data to the index
if value not in self.correlation_index:
self.correlation_index[value] = {}
if node_id not in self.correlation_index[value]:
self.correlation_index[value][node_id] = []
# Process each attribute for potential correlations # Store both the full path and the parent attribute for better edge labeling
for attr in node_attributes: correlation_entry = {
attr_name = attr.get('name') 'path': path_str,
attr_value = attr.get('value') 'parent_attr': parent_attr,
attr_provider = attr.get('provider', 'unknown') 'meaningful_attr': self._extract_meaningful_attribute(path_str, parent_attr)
}
if correlation_entry not in self.correlation_index[value][node_id]:
self.correlation_index[value][node_id].append(correlation_entry)
# Skip excluded attributes and invalid values def _extract_meaningful_attribute(self, path_str: str, parent_attr: str = "") -> str:
if attr_name in self.EXCLUDED_KEYS or not isinstance(attr_value, (str, int, float, bool)) or attr_value is None: """Extract the most meaningful attribute name from a path string."""
continue if not path_str:
return "unknown"
path_parts = path_str.split('.')
# Look for the last non-array-index part
for part in reversed(path_parts):
# Skip array indices like [0], [1], etc.
if not (part.startswith('[') and part.endswith(']') and part[1:-1].isdigit()):
# Clean up compound names like "hostnames[0]" to just "hostnames"
clean_part = re.sub(r'\[\d+\]$', '', part)
if clean_part:
return clean_part
# Fallback to parent attribute if available
if parent_attr:
return parent_attr
# Last resort - use the first meaningful part
for part in path_parts:
if not (part.startswith('[') and part.endswith(']') and part[1:-1].isdigit()):
clean_part = re.sub(r'\[\d+\]$', '', part)
if clean_part:
return clean_part
return "correlation"
if isinstance(attr_value, bool): def _check_for_correlations(self, new_node_id: str, data: Any, path: List[str] = [], parent_attr: str = "") -> List[Dict]:
continue """Recursively traverse metadata to find correlations with existing data."""
if path is None:
if isinstance(attr_value, str) and (len(attr_value) < 4 or self.date_pattern.match(attr_value)): path = []
continue
# Initialize correlation tracking for this value all_correlations = []
if attr_value not in self.correlation_index: if isinstance(data, dict):
self.correlation_index[attr_value] = { for key, value in data.items():
'nodes': set(), if key == 'source': # Avoid correlating on the provider name
'sources': [] # Track which provider/attribute combinations contributed continue
all_correlations.extend(self._check_for_correlations(new_node_id, value, path + [key], key))
elif isinstance(data, list):
for i, item in enumerate(data):
list_path_component = f"[{i}]" if not parent_attr else f"{parent_attr}[{i}]"
all_correlations.extend(self._check_for_correlations(new_node_id, item, path + [list_path_component], parent_attr))
else:
value = data
if value in self.correlation_index:
existing_nodes_with_paths = self.correlation_index[value]
unique_nodes = set(existing_nodes_with_paths.keys())
unique_nodes.add(new_node_id)
if len(unique_nodes) < 2:
return all_correlations # Correlation must involve at least two distinct nodes
new_source = {
'node_id': new_node_id,
'path': ".".join(path),
'parent_attr': parent_attr,
'meaningful_attr': self._extract_meaningful_attribute(".".join(path), parent_attr)
} }
all_sources = [new_source]
for node_id, path_entries in existing_nodes_with_paths.items():
for entry in path_entries:
if isinstance(entry, dict):
all_sources.append({
'node_id': node_id,
'path': entry['path'],
'parent_attr': entry.get('parent_attr', ''),
'meaningful_attr': entry.get('meaningful_attr', self._extract_meaningful_attribute(entry['path'], entry.get('parent_attr', '')))
})
else:
# Handle legacy string-only entries
all_sources.append({
'node_id': node_id,
'path': str(entry),
'parent_attr': '',
'meaningful_attr': self._extract_meaningful_attribute(str(entry))
})
# Add this node and source information all_correlations.append({
self.correlation_index[attr_value]['nodes'].add(node_id) 'value': value,
'sources': all_sources,
# Track the source of this correlation value 'nodes': list(unique_nodes)
source_info = { })
'node_id': node_id, return all_correlations
'provider': attr_provider,
'attribute': attr_name,
'path': f"{attr_provider}_{attr_name}"
}
# Add source if not already present (avoid duplicates)
existing_sources = [s for s in self.correlation_index[attr_value]['sources']
if s['node_id'] == node_id and s['path'] == source_info['path']]
if not existing_sources:
self.correlation_index[attr_value]['sources'].append(source_info)
# Create correlation node if we have multiple nodes with this value def add_node(self, node_id: str, node_type: NodeType, attributes: Optional[Dict[str, Any]] = None,
if len(self.correlation_index[attr_value]['nodes']) > 1: description: str = "", metadata: Optional[Dict[str, Any]] = None) -> bool:
self._create_enhanced_correlation_node_and_edges(attr_value, self.correlation_index[attr_value]) """Add a node to the graph, update attributes, and process correlations."""
is_new_node = not self.graph.has_node(node_id)
if is_new_node:
self.graph.add_node(node_id, type=node_type.value,
added_timestamp=datetime.now(timezone.utc).isoformat(),
attributes=attributes or {},
description=description,
metadata=metadata or {})
else:
# Safely merge new attributes into existing attributes
if attributes:
existing_attributes = self.graph.nodes[node_id].get('attributes', {})
existing_attributes.update(attributes)
self.graph.nodes[node_id]['attributes'] = existing_attributes
if description:
self.graph.nodes[node_id]['description'] = description
if metadata:
existing_metadata = self.graph.nodes[node_id].get('metadata', {})
existing_metadata.update(metadata)
self.graph.nodes[node_id]['metadata'] = existing_metadata
def _create_enhanced_correlation_node_and_edges(self, value, correlation_data): if attributes and node_type != NodeType.CORRELATION_OBJECT:
""" correlations = self._check_for_correlations(node_id, attributes)
UPDATED: Create correlation node and edges with detailed provider tracking. for corr in correlations:
""" value = corr['value']
correlation_node_id = f"corr_{hash(str(value)) & 0x7FFFFFFF}"
nodes = correlation_data['nodes'] # STEP 1: Substring check against all existing nodes
sources = correlation_data['sources'] if self._correlation_value_matches_existing_node(value):
# Skip creating correlation node - would be redundant
continue
eligible_nodes = set(corr['nodes'])
if len(eligible_nodes) < 2:
# Need at least 2 nodes to create a correlation
continue
# STEP 3: Check for existing correlation node with same connection pattern
correlation_nodes_with_pattern = self._find_correlation_nodes_with_same_pattern(eligible_nodes)
if correlation_nodes_with_pattern:
# STEP 4: Merge with existing correlation node
target_correlation_node = correlation_nodes_with_pattern[0]
self._merge_correlation_values(target_correlation_node, value, corr)
else:
# STEP 5: Create new correlation node for eligible nodes only
correlation_node_id = f"corr_{abs(hash(str(sorted(eligible_nodes))))}"
self.add_node(correlation_node_id, NodeType.CORRELATION_OBJECT,
metadata={'values': [value], 'sources': corr['sources'],
'correlated_nodes': list(eligible_nodes)})
# Create edges from eligible nodes to this correlation node with better labeling
for c_node_id in eligible_nodes:
if self.graph.has_node(c_node_id):
# Find the best attribute name for this node
meaningful_attr = self._find_best_attribute_name_for_node(c_node_id, corr['sources'])
relationship_type = f"c_{meaningful_attr}"
self.add_edge(c_node_id, correlation_node_id, relationship_type, confidence_score=0.9)
self._update_correlation_index(node_id, attributes)
self.last_modified = datetime.now(timezone.utc).isoformat()
return is_new_node
def _find_best_attribute_name_for_node(self, node_id: str, sources: List[Dict]) -> str:
"""Find the best attribute name for a correlation edge by looking at the sources."""
node_sources = [s for s in sources if s['node_id'] == node_id]
# Create or update correlation node if not node_sources:
if not self.graph.has_node(correlation_node_id): return "correlation"
# Determine the most common provider/attribute combination
provider_counts = {} # Use the meaningful_attr if available
for source in sources: for source in node_sources:
key = f"{source['provider']}_{source['attribute']}" meaningful_attr = source.get('meaningful_attr')
provider_counts[key] = provider_counts.get(key, 0) + 1 if meaningful_attr and meaningful_attr != "unknown":
return meaningful_attr
# Use the most common provider/attribute as the primary label
primary_source = max(provider_counts.items(), key=lambda x: x[1])[0] if provider_counts else "unknown_correlation" # Fallback to parent_attr
for source in node_sources:
metadata = { parent_attr = source.get('parent_attr')
'value': value, if parent_attr:
'correlated_nodes': list(nodes), return parent_attr
'sources': sources,
'primary_source': primary_source, # Last resort - extract from path
'correlation_count': len(nodes) for source in node_sources:
} path = source.get('path', '')
if path:
self.add_node(correlation_node_id, NodeType.CORRELATION_OBJECT, metadata=metadata) extracted = self._extract_meaningful_attribute(path)
print(f"Created correlation node {correlation_node_id} for value '{value}' with {len(nodes)} nodes") if extracted != "unknown":
return extracted
# Create edges from each node to the correlation node
for source in sources: return "correlation"
node_id = source['node_id']
provider = source['provider']
attribute = source['attribute']
if self.graph.has_node(node_id) and not self.graph.has_edge(node_id, correlation_node_id):
# Format relationship label as "corr_provider_attribute"
relationship_label = f"corr_{provider}_{attribute}"
self.add_edge(
source_id=node_id,
target_id=correlation_node_id,
relationship_type=relationship_label,
confidence_score=0.9,
source_provider=provider,
raw_data={
'correlation_value': value,
'original_attribute': attribute,
'correlation_type': 'attribute_matching'
}
)
print(f"Added correlation edge: {node_id} -> {correlation_node_id} ({relationship_label})")
def _has_direct_edge_bidirectional(self, node_a: str, node_b: str) -> bool: def _has_direct_edge_bidirectional(self, node_a: str, node_b: str) -> bool:
""" """
@@ -261,47 +382,6 @@ class GraphManager:
f"across {node_count} nodes" f"across {node_count} nodes"
) )
def add_node(self, node_id: str, node_type: NodeType, attributes: Optional[List[Dict[str, Any]]] = None,
description: str = "", metadata: Optional[Dict[str, Any]] = None) -> bool:
"""
Add a node to the graph, update attributes, and process correlations.
Now compatible with unified data model - attributes are dictionaries from converted StandardAttribute objects.
"""
is_new_node = not self.graph.has_node(node_id)
if is_new_node:
self.graph.add_node(node_id, type=node_type.value,
added_timestamp=datetime.now(timezone.utc).isoformat(),
attributes=attributes or [], # Store as a list from the start
description=description,
metadata=metadata or {})
else:
# Safely merge new attributes into the existing list of attributes
if attributes:
existing_attributes = self.graph.nodes[node_id].get('attributes', [])
# Handle cases where old data might still be in dictionary format
if not isinstance(existing_attributes, list):
existing_attributes = []
# Create a set of existing attribute names for efficient duplicate checking
existing_attr_names = {attr['name'] for attr in existing_attributes}
for new_attr in attributes:
if new_attr['name'] not in existing_attr_names:
existing_attributes.append(new_attr)
existing_attr_names.add(new_attr['name'])
self.graph.nodes[node_id]['attributes'] = existing_attributes
if description:
self.graph.nodes[node_id]['description'] = description
if metadata:
existing_metadata = self.graph.nodes[node_id].get('metadata', {})
existing_metadata.update(metadata)
self.graph.nodes[node_id]['metadata'] = existing_metadata
self.last_modified = datetime.now(timezone.utc).isoformat()
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", confidence_score: float = 0.5, source_provider: str = "unknown",
raw_data: Optional[Dict[str, Any]] = None) -> bool: raw_data: Optional[Dict[str, Any]] = None) -> bool:
@@ -333,63 +413,6 @@ class GraphManager:
raw_data=raw_data or {}) raw_data=raw_data or {})
self.last_modified = datetime.now(timezone.utc).isoformat() self.last_modified = datetime.now(timezone.utc).isoformat()
return True return True
def extract_node_from_large_entity(self, large_entity_id: str, node_id_to_extract: str) -> bool:
"""
Removes a node from a large entity's internal lists and updates its count.
This prepares the large entity for the node's promotion to a regular node.
"""
if not self.graph.has_node(large_entity_id):
return False
node_data = self.graph.nodes[large_entity_id]
attributes = node_data.get('attributes', {})
# Remove from the list of member nodes
if 'nodes' in attributes and node_id_to_extract in attributes['nodes']:
attributes['nodes'].remove(node_id_to_extract)
# Update the count
attributes['count'] = len(attributes['nodes'])
else:
# This can happen if the node was already extracted, which is not an error.
print(f"Warning: Node {node_id_to_extract} not found in the 'nodes' list of {large_entity_id}.")
return True # Proceed as if successful
self.last_modified = datetime.now(timezone.utc).isoformat()
return True
def remove_node(self, node_id: str) -> bool:
"""Remove a node and its connected edges from the graph."""
if not self.graph.has_node(node_id):
return False
# Remove node from the graph (NetworkX handles removing connected edges)
self.graph.remove_node(node_id)
# Clean up the correlation index
keys_to_delete = []
for value, data in self.correlation_index.items():
if isinstance(data, dict) and 'nodes' in data:
# Updated correlation structure
if node_id in data['nodes']:
data['nodes'].discard(node_id)
# Remove sources for this node
data['sources'] = [s for s in data['sources'] if s['node_id'] != node_id]
if not data['nodes']: # If no other nodes are associated, remove it
keys_to_delete.append(value)
else:
# Legacy correlation structure (fallback)
if isinstance(data, set) and node_id in data:
data.discard(node_id)
if not data:
keys_to_delete.append(value)
for key in keys_to_delete:
if key in self.correlation_index:
del self.correlation_index[key]
self.last_modified = datetime.now(timezone.utc).isoformat()
return True
def get_node_count(self) -> int: def get_node_count(self) -> int:
"""Get total number of nodes in the graph.""" """Get total number of nodes in the graph."""
@@ -415,58 +438,19 @@ class GraphManager:
if d.get('confidence_score', 0) >= min_confidence] 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.
UPDATED: Fixed certificate validity styling logic for unified data model.
"""
nodes = [] nodes = []
for node_id, attrs in self.graph.nodes(data=True): for node_id, attrs in self.graph.nodes(data=True):
node_data = {'id': node_id, 'label': node_id, 'type': attrs.get('type', 'unknown'), node_data = {'id': node_id, 'label': node_id, 'type': attrs.get('type', 'unknown'),
'attributes': attrs.get('attributes', []), # Ensure attributes is a list 'attributes': attrs.get('attributes', {}),
'description': attrs.get('description', ''), 'description': attrs.get('description', ''),
'metadata': attrs.get('metadata', {}), 'metadata': attrs.get('metadata', {}),
'added_timestamp': attrs.get('added_timestamp')} 'added_timestamp': attrs.get('added_timestamp')}
# Customize node appearance based on type and attributes
# UPDATED: Fixed certificate validity styling logic
node_type = node_data['type'] node_type = node_data['type']
attributes_list = node_data['attributes'] attributes = node_data['attributes']
if node_type == 'domain' and attributes.get('certificates', {}).get('has_valid_cert') is False:
if node_type == 'domain' and isinstance(attributes_list, list): node_data['color'] = {'background': '#c7c7c7', 'border': '#999'} # Gray for invalid cert
# Check for certificate-related attributes
has_certificates = False
has_valid_certificates = False
has_expired_certificates = False
for attr in attributes_list:
attr_name = attr.get('name', '').lower()
attr_provider = attr.get('provider', '').lower()
attr_value = attr.get('value')
# Look for certificate attributes from crt.sh provider
if attr_provider == 'crtsh' or 'cert' in attr_name:
has_certificates = True
# Check certificate validity
if attr_name == 'cert_is_currently_valid':
if attr_value is True:
has_valid_certificates = True
elif attr_value is False:
has_expired_certificates = True
# Also check for certificate expiry indicators
elif 'expires_soon' in attr_name and attr_value is True:
has_expired_certificates = True
elif 'expired' in attr_name and attr_value is True:
has_expired_certificates = True
# Apply styling based on certificate status
if has_expired_certificates and not has_valid_certificates:
# Red for expired/invalid certificates
node_data['color'] = {'background': '#ff6b6b', 'border': '#cc5555'}
elif not has_certificates:
# Grey for domains with no certificates
node_data['color'] = {'background': '#c7c7c7', 'border': '#999999'}
# Default green styling is handled by the frontend for domains with valid certificates
# Add incoming and outgoing edges to node data # Add incoming and outgoing edges to node data
if self.graph.has_node(node_id): if self.graph.has_node(node_id):
@@ -497,7 +481,7 @@ class GraphManager:
'last_modified': self.last_modified, 'last_modified': self.last_modified,
'total_nodes': self.get_node_count(), 'total_nodes': self.get_node_count(),
'total_edges': self.get_edge_count(), 'total_edges': self.get_edge_count(),
'graph_format': 'dnsrecon_v1_unified_model' 'graph_format': 'dnsrecon_v1_nodeling'
}, },
'graph': graph_data, 'graph': graph_data,
'statistics': self.get_statistics() 'statistics': self.get_statistics()

View File

@@ -50,7 +50,7 @@ class ForensicLogger:
session_id: Unique identifier for this reconnaissance session session_id: Unique identifier for this reconnaissance session
""" """
self.session_id = session_id or self._generate_session_id() self.session_id = session_id or self._generate_session_id()
self.lock = threading.Lock() #self.lock = threading.Lock()
# Initialize audit trail storage # Initialize audit trail storage
self.api_requests: List[APIRequest] = [] self.api_requests: List[APIRequest] = []
@@ -86,8 +86,6 @@ class ForensicLogger:
# Remove the unpickleable 'logger' attribute # Remove the unpickleable 'logger' attribute
if 'logger' in state: if 'logger' in state:
del state['logger'] del state['logger']
if 'lock' in state:
del state['lock']
return state return state
def __setstate__(self, state): def __setstate__(self, state):
@@ -103,7 +101,6 @@ class ForensicLogger:
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter) console_handler.setFormatter(formatter)
self.logger.addHandler(console_handler) self.logger.addHandler(console_handler)
self.lock = threading.Lock()
def _generate_session_id(self) -> str: def _generate_session_id(self) -> str:
"""Generate unique session identifier.""" """Generate unique session identifier."""

View File

@@ -1,106 +0,0 @@
# dnsrecon-reduced/core/provider_result.py
"""
Unified data model for DNSRecon passive reconnaissance.
Standardizes the data structure across all providers to ensure consistent processing.
"""
from typing import Any, Optional, List, Dict
from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass
class StandardAttribute:
"""A unified data structure for a single piece of information about a node."""
target_node: str
name: str
value: Any
type: str
provider: str
confidence: float
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
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
class Relationship:
"""A unified data structure for a directional link between two nodes."""
source_node: str
target_node: str
relationship_type: str
confidence: float
provider: str
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
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
class ProviderResult:
"""A container for all data returned by a provider from a single query."""
attributes: List[StandardAttribute] = 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,
provider: str, confidence: float = 0.8,
metadata: Optional[Dict[str, Any]] = None) -> None:
"""Helper method to add an attribute to the result."""
self.attributes.append(StandardAttribute(
target_node=target_node,
name=name,
value=value,
type=attr_type,
provider=provider,
confidence=confidence,
metadata=metadata or {}
))
def add_relationship(self, source_node: str, target_node: str, relationship_type: str,
provider: str, confidence: float = 0.8,
raw_data: Optional[Dict[str, Any]] = None) -> None:
"""Helper method to add a relationship to the result."""
self.relationships.append(Relationship(
source_node=source_node,
target_node=target_node,
relationship_type=relationship_type,
confidence=confidence,
provider=provider,
raw_data=raw_data or {}
))
def get_discovered_nodes(self) -> set:
"""Get all unique node identifiers discovered in this result."""
nodes = set()
# Add nodes from relationships
for rel in self.relationships:
nodes.add(rel.source_node)
nodes.add(rel.target_node)
# Add nodes from attributes
for attr in self.attributes:
nodes.add(attr.target_node)
return nodes
def get_relationship_count(self) -> int:
"""Get the total number of relationships in this result."""
return len(self.relationships)
def get_attribute_count(self) -> int:
"""Get the total number of attributes in this result."""
return len(self.attributes)
def is_large_entity(self, threshold: int) -> bool:
"""Check if this result qualifies as a large entity based on relationship count."""
return self.get_relationship_count() > threshold

View File

@@ -1,28 +0,0 @@
# dnsrecon-reduced/core/rate_limiter.py
import time
class GlobalRateLimiter:
def __init__(self, redis_client):
self.redis = redis_client
def is_rate_limited(self, key, limit, period):
"""
Check if a key is rate-limited.
"""
now = time.time()
key = f"rate_limit:{key}"
# Remove old timestamps
self.redis.zremrangebyscore(key, 0, now - period)
# Check the count
count = self.redis.zcard(key)
if count >= limit:
return True
# Add new timestamp
self.redis.zadd(key, {now: now})
self.redis.expire(key, period)
return False

View File

@@ -1,22 +1,20 @@
# dnsrecon-reduced/core/scanner.py # dnsrecon/core/scanner.py
import threading import threading
import traceback import traceback
import time
import os import os
import importlib import importlib
import redis
from typing import List, Set, Dict, Any, Tuple, Optional from typing import List, Set, Dict, Any, Tuple, Optional
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError, Future
from collections import defaultdict from collections import defaultdict, deque
from queue import PriorityQueue
from datetime import datetime, timezone from datetime import datetime, timezone
from core.graph_manager import GraphManager, NodeType from core.graph_manager import GraphManager, NodeType
from core.logger import get_forensic_logger, new_session from core.logger import get_forensic_logger, new_session
from core.provider_result import ProviderResult
from utils.helpers import _is_valid_ip, _is_valid_domain from utils.helpers import _is_valid_ip, _is_valid_domain
from providers.base_provider import BaseProvider from providers.base_provider import BaseProvider
from core.rate_limiter import GlobalRateLimiter
class ScanStatus: class ScanStatus:
"""Enumeration of scan statuses.""" """Enumeration of scan statuses."""
@@ -30,7 +28,6 @@ class ScanStatus:
class Scanner: class Scanner:
""" """
Main scanning orchestrator for DNSRecon passive reconnaissance. Main scanning orchestrator for DNSRecon passive reconnaissance.
Now provider-agnostic, consuming standardized ProviderResult objects.
""" """
def __init__(self, session_config=None): def __init__(self, session_config=None):
@@ -53,7 +50,7 @@ class Scanner:
self.stop_event = threading.Event() self.stop_event = threading.Event()
self.scan_thread = None self.scan_thread = None
self.session_id: Optional[str] = None # Will be set by session manager self.session_id: Optional[str] = None # Will be set by session manager
self.task_queue = PriorityQueue() self.task_queue = deque([])
self.target_retries = defaultdict(int) self.target_retries = defaultdict(int)
self.scan_failed_due_to_retries = False self.scan_failed_due_to_retries = False
@@ -66,7 +63,6 @@ class Scanner:
self.indicators_processed = 0 self.indicators_processed = 0
self.indicators_completed = 0 self.indicators_completed = 0
self.tasks_re_enqueued = 0 self.tasks_re_enqueued = 0
self.total_tasks_ever_enqueued = 0
self.current_indicator = "" self.current_indicator = ""
# Concurrent processing configuration # Concurrent processing configuration
@@ -80,9 +76,6 @@ class Scanner:
# Initialize logger # Initialize logger
print("Initializing forensic logger...") print("Initializing forensic logger...")
self.logger = get_forensic_logger() self.logger = get_forensic_logger()
# Initialize global rate limiter
self.rate_limiter = GlobalRateLimiter(redis.StrictRedis(db=0))
print("Scanner initialization complete") print("Scanner initialization complete")
@@ -136,10 +129,7 @@ class Scanner:
'stop_event', 'stop_event',
'scan_thread', 'scan_thread',
'executor', 'executor',
'processing_lock', 'processing_lock' # **NEW**: Exclude the processing lock
'task_queue',
'rate_limiter',
'logger'
] ]
for attr in unpicklable_attrs: for attr in unpicklable_attrs:
@@ -148,6 +138,7 @@ class Scanner:
# Handle providers separately to ensure they're picklable # Handle providers separately to ensure they're picklable
if 'providers' in state: if 'providers' in state:
# The providers should be picklable now, but let's ensure clean state
for provider in state['providers']: for provider in state['providers']:
if hasattr(provider, '_stop_event'): if hasattr(provider, '_stop_event'):
provider._stop_event = None provider._stop_event = None
@@ -162,15 +153,9 @@ class Scanner:
self.stop_event = threading.Event() self.stop_event = threading.Event()
self.scan_thread = None self.scan_thread = None
self.executor = None self.executor = None
self.processing_lock = threading.Lock() self.processing_lock = threading.Lock() # **NEW**: Recreate processing lock
self.task_queue = PriorityQueue()
self.rate_limiter = GlobalRateLimiter(redis.StrictRedis(db=0))
self.logger = get_forensic_logger()
if not hasattr(self, 'providers') or not self.providers:
print("Providers not found after loading session, re-initializing...")
self._initialize_providers()
# **NEW**: Reset processing tracking
if not hasattr(self, 'currently_processing'): if not hasattr(self, 'currently_processing'):
self.currently_processing = set() self.currently_processing = set()
@@ -219,12 +204,11 @@ class Scanner:
self._initialize_providers() self._initialize_providers()
print("Session configuration updated") print("Session configuration updated")
def start_scan(self, target: str, max_depth: int = 2, clear_graph: bool = True, force_rescan_target: Optional[str] = None) -> bool: def start_scan(self, target_domain: str, max_depth: int = 2, clear_graph: bool = True) -> bool:
"""Start a new reconnaissance scan with proper cleanup of previous scans.""" """Start a new reconnaissance scan with proper cleanup of previous scans."""
print(f"=== STARTING SCAN IN SCANNER {id(self)} ===") print(f"=== STARTING SCAN IN SCANNER {id(self)} ===")
print(f"Session ID: {self.session_id}") print(f"Session ID: {self.session_id}")
print(f"Initial scanner status: {self.status}") print(f"Initial scanner status: {self.status}")
self.total_tasks_ever_enqueued = 0
# **IMPROVED**: More aggressive cleanup of previous scan # **IMPROVED**: More aggressive cleanup of previous scan
if self.scan_thread and self.scan_thread.is_alive(): if self.scan_thread and self.scan_thread.is_alive():
@@ -237,7 +221,7 @@ class Scanner:
# Clear all processing state # Clear all processing state
with self.processing_lock: with self.processing_lock:
self.currently_processing.clear() self.currently_processing.clear()
self.task_queue = PriorityQueue() self.task_queue.clear()
# Shutdown executor aggressively # Shutdown executor aggressively
if self.executor: if self.executor:
@@ -267,7 +251,7 @@ class Scanner:
with self.processing_lock: with self.processing_lock:
self.currently_processing.clear() self.currently_processing.clear()
self.task_queue = PriorityQueue() self.task_queue.clear()
self.target_retries.clear() self.target_retries.clear()
self.scan_failed_due_to_retries = False self.scan_failed_due_to_retries = False
@@ -284,14 +268,7 @@ class Scanner:
if clear_graph: if clear_graph:
self.graph.clear() self.graph.clear()
self.current_target = target_domain.lower().strip()
if force_rescan_target and self.graph.graph.has_node(force_rescan_target):
print(f"Forcing rescan of {force_rescan_target}, clearing provider states.")
node_data = self.graph.graph.nodes[force_rescan_target]
if 'metadata' in node_data and 'provider_states' in node_data['metadata']:
node_data['metadata']['provider_states'] = {}
self.current_target = target.lower().strip()
self.max_depth = max_depth self.max_depth = max_depth
self.current_depth = 0 self.current_depth = 0
@@ -327,119 +304,95 @@ class Scanner:
self._update_session_state() self._update_session_state()
return False return False
def _get_priority(self, provider_name): def _execute_scan(self, target_domain: str, max_depth: int) -> None:
rate_limit = self.config.get_rate_limit(provider_name)
if rate_limit > 90:
return 1 # Highest priority
elif rate_limit > 50:
return 2
else:
return 3 # Lowest priority
def _execute_scan(self, target: str, max_depth: int) -> None:
"""Execute the reconnaissance scan with proper termination handling.""" """Execute the reconnaissance scan with proper termination handling."""
print(f"_execute_scan started for {target} with depth {max_depth}") print(f"_execute_scan started for {target_domain} with depth {max_depth}")
self.executor = ThreadPoolExecutor(max_workers=self.max_workers) self.executor = ThreadPoolExecutor(max_workers=self.max_workers)
processed_tasks = set() processed_targets = set()
# Initial task population for the main target self.task_queue.append((target_domain, 0, False))
is_ip = _is_valid_ip(target)
initial_providers = self._get_eligible_providers(target, is_ip, False)
for provider in initial_providers:
provider_name = provider.get_name()
self.task_queue.put((self._get_priority(provider_name), (provider_name, target, 0)))
self.total_tasks_ever_enqueued += 1
try: try:
self.status = ScanStatus.RUNNING self.status = ScanStatus.RUNNING
self._update_session_state() self._update_session_state()
enabled_providers = [provider.get_name() for provider in self.providers] enabled_providers = [provider.get_name() for provider in self.providers]
self.logger.log_scan_start(target, max_depth, enabled_providers) self.logger.log_scan_start(target_domain, max_depth, enabled_providers)
self.graph.add_node(target_domain, NodeType.DOMAIN)
self._initialize_provider_states(target_domain)
# Determine initial node type # **IMPROVED**: Better termination checking in main loop
node_type = NodeType.IP if is_ip else NodeType.DOMAIN while self.task_queue and not self._is_stop_requested():
self.graph.add_node(target, node_type)
self._initialize_provider_states(target)
# Better termination checking in main loop
while not self.task_queue.empty() and not self._is_stop_requested():
try: try:
priority, (provider_name, target_item, depth) = self.task_queue.get() target, depth, is_large_entity_member = self.task_queue.popleft()
except IndexError: except IndexError:
# Queue became empty during processing # Queue became empty during processing
break break
task_tuple = (provider_name, target_item) if target in processed_targets:
if task_tuple in processed_tasks:
continue continue
if depth > max_depth: if depth > max_depth:
continue continue
if self.rate_limiter.is_rate_limited(provider_name, self.config.get_rate_limit(provider_name), 60): # **NEW**: Track this target as currently processing
self.task_queue.put((priority + 1, (provider_name, target_item, depth))) # Postpone
continue
with self.processing_lock: with self.processing_lock:
if self._is_stop_requested(): if self._is_stop_requested():
print(f"Stop requested before processing {target_item}") print(f"Stop requested before processing {target}")
break break
self.currently_processing.add(target_item) self.currently_processing.add(target)
try: try:
self.current_depth = depth self.current_depth = depth
self.current_indicator = target_item self.current_indicator = target
self._update_session_state() self._update_session_state()
# **IMPROVED**: More frequent stop checking during processing
if self._is_stop_requested(): if self._is_stop_requested():
print(f"Stop requested during processing setup for {target_item}") print(f"Stop requested during processing setup for {target}")
break break
provider = next((p for p in self.providers if p.get_name() == provider_name), None) new_targets, large_entity_members, success = self._query_providers_for_target(target, depth, is_large_entity_member)
if provider: # **NEW**: Check stop signal after provider queries
new_targets, large_entity_members, success = self._query_single_provider_for_target(provider, target_item, depth) if self._is_stop_requested():
print(f"Stop requested after querying providers for {target}")
if self._is_stop_requested(): break
print(f"Stop requested after querying providers for {target_item}")
break if not success:
self.target_retries[target] += 1
if not success: if self.target_retries[target] <= self.config.max_retries_per_target:
self.target_retries[task_tuple] += 1 print(f"Re-queueing target {target} (attempt {self.target_retries[target]})")
if self.target_retries[task_tuple] <= self.config.max_retries_per_target: self.task_queue.append((target, depth, is_large_entity_member))
print(f"Re-queueing task {task_tuple} (attempt {self.target_retries[task_tuple]})") self.tasks_re_enqueued += 1
self.task_queue.put((priority, (provider_name, target_item, depth)))
self.tasks_re_enqueued += 1
self.total_tasks_ever_enqueued += 1
else:
print(f"ERROR: Max retries exceeded for task {task_tuple}")
self.scan_failed_due_to_retries = True
self._log_target_processing_error(str(task_tuple), "Max retries exceeded")
else: else:
processed_tasks.add(task_tuple) print(f"ERROR: Max retries exceeded for target {target}")
self.indicators_completed += 1 self.scan_failed_due_to_retries = True
self._log_target_processing_error(target, "Max retries exceeded")
if not self._is_stop_requested(): else:
all_new_targets = new_targets.union(large_entity_members) processed_targets.add(target)
for new_target in all_new_targets: self.indicators_completed += 1
is_ip_new = _is_valid_ip(new_target)
eligible_providers_new = self._get_eligible_providers(new_target, is_ip_new, False) # **NEW**: Only add new targets if not stopped
for p_new in eligible_providers_new: if not self._is_stop_requested():
p_name_new = p_new.get_name() for new_target in new_targets:
if (p_name_new, new_target) not in processed_tasks: if new_target not in processed_targets:
new_depth = depth + 1 if new_target in new_targets else depth self.task_queue.append((new_target, depth + 1, False))
self.task_queue.put((self._get_priority(p_name_new), (p_name_new, new_target, new_depth)))
self.total_tasks_ever_enqueued += 1 for member in large_entity_members:
if member not in processed_targets:
self.task_queue.append((member, depth, True))
finally: finally:
# **NEW**: Always remove from processing set
with self.processing_lock: with self.processing_lock:
self.currently_processing.discard(target_item) self.currently_processing.discard(target)
# **NEW**: Log termination reason
if self._is_stop_requested(): if self._is_stop_requested():
print("Scan terminated due to stop request") print("Scan terminated due to stop request")
self.logger.logger.info("Scan terminated by user request") self.logger.logger.info("Scan terminated by user request")
elif self.task_queue.empty(): elif not self.task_queue:
print("Scan completed - no more targets to process") print("Scan completed - no more targets to process")
self.logger.logger.info("Scan completed - all targets processed") self.logger.logger.info("Scan completed - all targets processed")
@@ -449,16 +402,17 @@ class Scanner:
self.status = ScanStatus.FAILED self.status = ScanStatus.FAILED
self.logger.logger.error(f"Scan failed: {e}") self.logger.logger.error(f"Scan failed: {e}")
finally: finally:
# **NEW**: Clear processing state on exit
with self.processing_lock: with self.processing_lock:
self.currently_processing.clear() self.currently_processing.clear()
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:
self.status = ScanStatus.FAILED self.status = ScanStatus.FAILED
else: else:
self.status = ScanStatus.COMPLETED self.status = ScanStatus.COMPLETED
self._update_session_state() self._update_session_state()
self.logger.log_scan_complete() self.logger.log_scan_complete()
if self.executor: if self.executor:
@@ -468,221 +422,68 @@ class Scanner:
print("Final scan statistics:") print("Final scan statistics:")
print(f" - Total nodes: {stats['basic_metrics']['total_nodes']}") print(f" - Total nodes: {stats['basic_metrics']['total_nodes']}")
print(f" - Total edges: {stats['basic_metrics']['total_edges']}") print(f" - Total edges: {stats['basic_metrics']['total_edges']}")
print(f" - Tasks processed: {len(processed_tasks)}") print(f" - Targets processed: {len(processed_targets)}")
def _query_single_provider_for_target(self, provider: BaseProvider, target: str, depth: int) -> Tuple[Set[str], Set[str], bool]: def _query_providers_for_target(self, target: str, depth: int, dns_only: bool = False) -> Tuple[Set[str], Set[str], bool]:
""" """Query providers for a single target with enhanced stop checking."""
Query a single provider and process the unified ProviderResult. # **NEW**: Early termination check
Now provider-agnostic - handles any provider that returns ProviderResult.
"""
if self._is_stop_requested(): if self._is_stop_requested():
print(f"Stop requested before querying {provider.get_name()} for {target}") print(f"Stop requested before querying providers for {target}")
return set(), set(), False return set(), set(), False
is_ip = _is_valid_ip(target) is_ip = _is_valid_ip(target)
target_type = NodeType.IP if is_ip else NodeType.DOMAIN target_type = NodeType.IP if is_ip else NodeType.DOMAIN
print(f"Querying {provider.get_name()} for {target_type.value}: {target} at depth {depth}") print(f"Querying providers for {target_type.value}: {target} at depth {depth}")
# Ensure target node exists in graph
self.graph.add_node(target, target_type) self.graph.add_node(target, target_type)
self._initialize_provider_states(target) self._initialize_provider_states(target)
new_targets = set() new_targets = set()
large_entity_members = set() large_entity_members = set()
provider_successful = True node_attributes = defaultdict(lambda: defaultdict(list))
all_providers_successful = True
try: eligible_providers = self._get_eligible_providers(target, is_ip, dns_only)
# Query provider - now returns unified ProviderResult
provider_result = self._query_single_provider_unified(provider, target, is_ip, depth) if not eligible_providers:
self._log_no_eligible_providers(target, is_ip)
if provider_result is None: return new_targets, large_entity_members, True
provider_successful = False
elif not self._is_stop_requested():
# Process the unified result
discovered, is_large_entity = self._process_provider_result_unified(
target, provider, provider_result, depth
)
if is_large_entity:
large_entity_members.update(discovered)
else:
new_targets.update(discovered)
self.graph.process_correlations_for_node(target)
else:
print(f"Stop requested after processing results from {provider.get_name()}")
except Exception as e:
provider_successful = False
self._log_provider_error(target, provider.get_name(), str(e))
return new_targets, large_entity_members, provider_successful # **IMPROVED**: Check stop signal before each provider
for i, provider in enumerate(eligible_providers):
def _query_single_provider_unified(self, provider: BaseProvider, target: str, is_ip: bool, current_depth: int) -> Optional[ProviderResult]:
"""
Query a single provider with stop signal checking, now returns ProviderResult.
"""
provider_name = provider.get_name()
start_time = datetime.now(timezone.utc)
if self._is_stop_requested():
print(f"Stop requested before querying {provider_name} for {target}")
return None
print(f"Querying {provider_name} for {target}")
self.logger.logger.info(f"Attempting {provider_name} query for {target} at depth {current_depth}")
try:
# Query the provider - returns unified ProviderResult
if is_ip:
result = provider.query_ip(target)
else:
result = provider.query_domain(target)
if self._is_stop_requested(): if self._is_stop_requested():
print(f"Stop requested after querying {provider_name} for {target}") print(f"Stop requested while querying provider {i+1}/{len(eligible_providers)} for {target}")
return None all_providers_successful = False
# Update provider state with relationship count (more meaningful than raw result count)
relationship_count = result.get_relationship_count() if result else 0
self._update_provider_state(target, provider_name, 'success', relationship_count, None, start_time)
print(f"{provider_name} returned {relationship_count} relationships for {target}")
return result
except Exception as e:
self._update_provider_state(target, provider_name, 'failed', 0, str(e), start_time)
print(f"{provider_name} failed for {target}: {e}")
return None
def _process_provider_result_unified(self, target: str, provider: BaseProvider,
provider_result: ProviderResult, current_depth: int) -> Tuple[Set[str], bool]:
"""
Process a unified ProviderResult object to update the graph.
Returns (discovered_targets, is_large_entity).
"""
provider_name = provider.get_name()
discovered_targets = set()
if self._is_stop_requested():
print(f"Stop requested before processing results from {provider_name} for {target}")
return discovered_targets, False
# Check for large entity based on relationship count
if provider_result.get_relationship_count() > self.config.large_entity_threshold:
print(f"Large entity detected: {provider_name} returned {provider_result.get_relationship_count()} relationships for {target}")
members = self._create_large_entity_from_provider_result(target, provider_name, provider_result, current_depth)
return members, True
# Process relationships
for i, relationship in enumerate(provider_result.relationships):
if i % 5 == 0 and self._is_stop_requested(): # Check periodically for stop
print(f"Stop requested while processing relationships from {provider_name} for {target}")
break break
# Add nodes for relationship endpoints
source_node = relationship.source_node
target_node = relationship.target_node
# Determine node types try:
source_type = NodeType.IP if _is_valid_ip(source_node) else NodeType.DOMAIN provider_results = self._query_single_provider_forensic(provider, target, is_ip, depth)
if target_node.startswith('AS') and target_node[2:].isdigit(): if provider_results is None:
target_type = NodeType.ASN all_providers_successful = False
elif _is_valid_ip(target_node): elif not self._is_stop_requested():
target_type = NodeType.IP discovered, is_large_entity = self._process_provider_results_forensic(
else: target, provider, provider_results, node_attributes, depth
target_type = NodeType.DOMAIN )
if is_large_entity:
# Add nodes to graph large_entity_members.update(discovered)
self.graph.add_node(source_node, source_type) else:
self.graph.add_node(target_node, target_type) new_targets.update(discovered)
# Add edge to graph
if self.graph.add_edge(
source_node, target_node,
relationship.relationship_type,
relationship.confidence,
provider_name,
relationship.raw_data
):
print(f"Added relationship: {source_node} -> {target_node} ({relationship.relationship_type})")
# Track discovered targets for further processing
if _is_valid_domain(target_node) or _is_valid_ip(target_node):
discovered_targets.add(target_node)
# Process attributes, preserving them as a list of objects
attributes_by_node = defaultdict(list)
for attribute in provider_result.attributes:
# Convert the StandardAttribute object to a dictionary that the frontend can use
attr_dict = {
"name": attribute.name,
"value": attribute.value,
"type": attribute.type,
"provider": attribute.provider,
"confidence": attribute.confidence,
"metadata": attribute.metadata
}
attributes_by_node[attribute.target_node].append(attr_dict)
# Add attributes to nodes
for node_id, node_attributes_list in attributes_by_node.items():
if self.graph.graph.has_node(node_id):
# Determine node type
if _is_valid_ip(node_id):
node_type = NodeType.IP
elif node_id.startswith('AS') and node_id[2:].isdigit():
node_type = NodeType.ASN
else: else:
node_type = NodeType.DOMAIN print(f"Stop requested after processing results from {provider.get_name()}")
break
# Add node with the list of attributes except Exception as e:
self.graph.add_node(node_id, node_type, attributes=node_attributes_list) all_providers_successful = False
self._log_provider_error(target, provider.get_name(), str(e))
return discovered_targets, False # **NEW**: Only update node attributes if not stopped
if not self._is_stop_requested():
for node_id, attributes in node_attributes.items():
if self.graph.graph.has_node(node_id):
node_is_ip = _is_valid_ip(node_id)
node_type_to_add = NodeType.IP if node_is_ip else NodeType.DOMAIN
self.graph.add_node(node_id, node_type_to_add, attributes=attributes)
def _create_large_entity_from_provider_result(self, source: str, provider_name: str, return new_targets, large_entity_members, all_providers_successful
provider_result: ProviderResult, current_depth: int) -> Set[str]:
"""
Create a large entity node from a ProviderResult and return the members for DNS processing.
"""
entity_id = f"large_entity_{provider_name}_{hash(source) & 0x7FFFFFFF}"
# Extract target nodes from relationships
targets = [rel.target_node for rel in provider_result.relationships]
node_type = 'unknown'
if targets:
if _is_valid_domain(targets[0]):
node_type = 'domain'
elif _is_valid_ip(targets[0]):
node_type = 'ip'
# Create nodes in graph (they exist but are grouped)
for target in targets:
target_node_type = NodeType.DOMAIN if node_type == 'domain' else NodeType.IP
self.graph.add_node(target, target_node_type)
attributes = {
'count': len(targets),
'nodes': targets,
'node_type': node_type,
'source_provider': provider_name,
'discovery_depth': current_depth,
'threshold_exceeded': self.config.large_entity_threshold,
}
description = f'Large entity created due to {len(targets)} relationships from {provider_name}'
self.graph.add_node(entity_id, NodeType.LARGE_ENTITY, attributes=attributes, description=description)
# Create edge from source to large entity
if provider_result.relationships:
rel_type = provider_result.relationships[0].relationship_type
self.graph.add_edge(source, entity_id, rel_type, 0.9, provider_name,
{'large_entity_info': f'Contains {len(targets)} {node_type}s'})
self.logger.logger.warning(f"Large entity created: {entity_id} contains {len(targets)} targets from {provider_name}")
print(f"Created large entity {entity_id} for {len(targets)} {node_type}s from {provider_name}")
return set(targets)
def stop_scan(self) -> bool: def stop_scan(self) -> bool:
"""Request immediate scan termination with proper cleanup.""" """Request immediate scan termination with proper cleanup."""
@@ -701,10 +502,8 @@ class Scanner:
print(f"Cleared {len(currently_processing_copy)} currently processing targets: {currently_processing_copy}") print(f"Cleared {len(currently_processing_copy)} currently processing targets: {currently_processing_copy}")
# **IMPROVED**: Clear task queue and log what was discarded # **IMPROVED**: Clear task queue and log what was discarded
discarded_tasks = [] discarded_tasks = list(self.task_queue)
while not self.task_queue.empty(): self.task_queue.clear()
discarded_tasks.append(self.task_queue.get())
self.task_queue = PriorityQueue()
print(f"Discarded {len(discarded_tasks)} pending tasks") print(f"Discarded {len(discarded_tasks)} pending tasks")
# **IMPROVED**: Aggressively shut down executor # **IMPROVED**: Aggressively shut down executor
@@ -729,73 +528,6 @@ class Scanner:
traceback.print_exc() traceback.print_exc()
return False return False
def extract_node_from_large_entity(self, large_entity_id: str, node_id_to_extract: str) -> bool:
"""
Extracts a node from a large entity, re-creates its original edge, and
re-queues it for full scanning.
"""
if not self.graph.graph.has_node(large_entity_id):
print(f"ERROR: Large entity {large_entity_id} not found.")
return False
# 1. Get the original source node that discovered the large entity
predecessors = list(self.graph.graph.predecessors(large_entity_id))
if not predecessors:
print(f"ERROR: No source node found for large entity {large_entity_id}.")
return False
source_node_id = predecessors[0]
# Get the original edge data to replicate it for the extracted node
original_edge_data = self.graph.graph.get_edge_data(source_node_id, large_entity_id)
if not original_edge_data:
print(f"ERROR: Could not find original edge data from {source_node_id} to {large_entity_id}.")
return False
# 2. Modify the graph data structure first
success = self.graph.extract_node_from_large_entity(large_entity_id, node_id_to_extract)
if not success:
print(f"ERROR: Node {node_id_to_extract} could not be removed from {large_entity_id}'s attributes.")
return False
# 3. Create the direct edge from the original source to the newly extracted node
print(f"Re-creating direct edge from {source_node_id} to extracted node {node_id_to_extract}")
self.graph.add_edge(
source_id=source_node_id,
target_id=node_id_to_extract,
relationship_type=original_edge_data.get('relationship_type', 'extracted_from_large_entity'),
confidence_score=original_edge_data.get('confidence_score', 0.85), # Slightly lower confidence
source_provider=original_edge_data.get('source_provider', 'unknown'),
raw_data={'context': f'Extracted from large entity {large_entity_id}'}
)
# 4. Re-queue the extracted node for full processing by all eligible providers
print(f"Re-queueing extracted node {node_id_to_extract} for full reconnaissance...")
is_ip = _is_valid_ip(node_id_to_extract)
current_depth = self.graph.graph.nodes[large_entity_id].get('attributes', {}).get('discovery_depth', 0)
eligible_providers = self._get_eligible_providers(node_id_to_extract, is_ip, False)
for provider in eligible_providers:
provider_name = provider.get_name()
self.task_queue.put((self._get_priority(provider_name), (provider_name, node_id_to_extract, current_depth)))
self.total_tasks_ever_enqueued += 1
# 5. If the scanner is not running, we need to kickstart it to process this one item.
if self.status != ScanStatus.RUNNING:
print("Scanner is idle. Starting a mini-scan to process the extracted node.")
self.status = ScanStatus.RUNNING
self._update_session_state()
if not self.scan_thread or not self.scan_thread.is_alive():
self.scan_thread = threading.Thread(
target=self._execute_scan,
args=(self.current_target, self.max_depth),
daemon=True
)
self.scan_thread.start()
print(f"Successfully extracted and re-queued {node_id_to_extract} from {large_entity_id}.")
return True
def _update_session_state(self) -> None: def _update_session_state(self) -> None:
""" """
Update the scanner state in Redis for GUI updates. Update the scanner state in Redis for GUI updates.
@@ -827,12 +559,11 @@ class Scanner:
'indicators_completed': self.indicators_completed, 'indicators_completed': self.indicators_completed,
'tasks_re_enqueued': self.tasks_re_enqueued, 'tasks_re_enqueued': self.tasks_re_enqueued,
'progress_percentage': self._calculate_progress(), 'progress_percentage': self._calculate_progress(),
'total_tasks_ever_enqueued': self.total_tasks_ever_enqueued,
'enabled_providers': [provider.get_name() for provider in self.providers], 'enabled_providers': [provider.get_name() for provider in self.providers],
'graph_statistics': self.graph.get_statistics(), 'graph_statistics': self.graph.get_statistics(),
'task_queue_size': self.task_queue.qsize(), 'task_queue_size': len(self.task_queue),
'currently_processing_count': currently_processing_count, 'currently_processing_count': currently_processing_count, # **NEW**
'currently_processing': currently_processing_list[:5] 'currently_processing': currently_processing_list[:5] # **NEW**: Show first 5 for debugging
} }
except Exception as e: except Exception as e:
print(f"ERROR: Exception in get_scan_status: {e}") print(f"ERROR: Exception in get_scan_status: {e}")
@@ -894,6 +625,39 @@ class Scanner:
provider_state = provider_states.get(provider_name) provider_state = provider_states.get(provider_name)
return provider_state is not None and provider_state.get('status') == 'success' return provider_state is not None and provider_state.get('status') == 'success'
def _query_single_provider_forensic(self, provider, target: str, is_ip: bool, current_depth: int) -> Optional[List]:
"""Query a single provider with stop signal checking."""
provider_name = provider.get_name()
start_time = datetime.now(timezone.utc)
if self._is_stop_requested():
print(f"Stop requested before querying {provider_name} for {target}")
return None
print(f"Querying {provider_name} for {target}")
self.logger.logger.info(f"Attempting {provider_name} query for {target} at depth {current_depth}")
try:
if is_ip:
results = provider.query_ip(target)
else:
results = provider.query_domain(target)
if self._is_stop_requested():
print(f"Stop requested after querying {provider_name} for {target}")
return None
self._update_provider_state(target, provider_name, 'success', len(results), None, start_time)
print(f"{provider_name} returned {len(results)} results for {target}")
return results
except Exception as e:
self._update_provider_state(target, provider_name, 'failed', 0, str(e), start_time)
print(f"{provider_name} failed for {target}: {e}")
return None
def _update_provider_state(self, target: str, provider_name: str, status: str, def _update_provider_state(self, target: str, provider_name: str, status: str,
results_count: int, error: Optional[str], start_time: datetime) -> None: results_count: int, error: Optional[str], start_time: datetime) -> None:
"""Update provider state in node metadata for forensic tracking.""" """Update provider state in node metadata for forensic tracking."""
@@ -916,6 +680,159 @@ class Scanner:
self.logger.logger.info(f"Provider state updated: {target} -> {provider_name} -> {status} ({results_count} results)") self.logger.logger.info(f"Provider state updated: {target} -> {provider_name} -> {status} ({results_count} results)")
def _process_provider_results_forensic(self, target: str, provider, results: List,
node_attributes: Dict, current_depth: int) -> Tuple[Set[str], bool]:
"""Process provider results, returns (discovered_targets, is_large_entity)."""
provider_name = provider.get_name()
discovered_targets = set()
if self._is_stop_requested():
print(f"Stop requested before processing results from {provider_name} for {target}")
return discovered_targets, False
if len(results) > self.config.large_entity_threshold:
print(f"Large entity detected: {provider_name} returned {len(results)} results for {target}")
members = self._create_large_entity(target, provider_name, results, current_depth)
return members, True
for i, (source, rel_target, rel_type, confidence, raw_data) in enumerate(results):
if i % 5 == 0 and self._is_stop_requested(): # Check more frequently
print(f"Stop requested while processing results from {provider_name} for {target}")
break
self.logger.log_relationship_discovery(
source_node=source,
target_node=rel_target,
relationship_type=rel_type,
confidence_score=confidence,
provider=provider_name,
raw_data=raw_data,
discovery_method=f"{provider_name}_query_depth_{current_depth}"
)
self._collect_node_attributes(source, provider_name, rel_type, rel_target, raw_data, node_attributes[source])
if isinstance(rel_target, list):
# If the target is a list, iterate and process each item
for single_target in rel_target:
if _is_valid_ip(single_target):
self.graph.add_node(single_target, NodeType.IP)
if self.graph.add_edge(source, single_target, rel_type, confidence, provider_name, raw_data):
print(f"Added IP relationship: {source} -> {single_target} ({rel_type})")
discovered_targets.add(single_target)
elif _is_valid_domain(single_target):
self.graph.add_node(single_target, NodeType.DOMAIN)
if self.graph.add_edge(source, single_target, rel_type, confidence, provider_name, raw_data):
print(f"Added domain relationship: {source} -> {single_target} ({rel_type})")
discovered_targets.add(single_target)
self._collect_node_attributes(single_target, provider_name, rel_type, source, raw_data, node_attributes[single_target])
elif _is_valid_ip(rel_target):
self.graph.add_node(rel_target, NodeType.IP)
if self.graph.add_edge(source, rel_target, rel_type, confidence, provider_name, raw_data):
print(f"Added IP relationship: {source} -> {rel_target} ({rel_type})")
discovered_targets.add(rel_target)
elif rel_target.startswith('AS') and rel_target[2:].isdigit():
self.graph.add_node(rel_target, NodeType.ASN)
if self.graph.add_edge(source, rel_target, rel_type, confidence, provider_name, raw_data):
print(f"Added ASN relationship: {source} -> {rel_target} ({rel_type})")
elif _is_valid_domain(rel_target):
self.graph.add_node(rel_target, NodeType.DOMAIN)
if self.graph.add_edge(source, rel_target, rel_type, confidence, provider_name, raw_data):
print(f"Added domain relationship: {source} -> {rel_target} ({rel_type})")
discovered_targets.add(rel_target)
self._collect_node_attributes(rel_target, provider_name, rel_type, source, raw_data, node_attributes[rel_target])
else:
self._collect_node_attributes(source, provider_name, rel_type, rel_target, raw_data, node_attributes[source])
return discovered_targets, False
def _create_large_entity(self, source: str, provider_name: str, results: List, current_depth: int) -> Set[str]:
"""Create a large entity node and returns the members for DNS processing."""
entity_id = f"large_entity_{provider_name}_{hash(source) & 0x7FFFFFFF}"
targets = [rel[1] for rel in results if len(rel) > 1]
node_type = 'unknown'
if targets:
if _is_valid_domain(targets[0]):
node_type = 'domain'
elif _is_valid_ip(targets[0]):
node_type = 'ip'
for target in targets:
self.graph.add_node(target, NodeType.DOMAIN if node_type == 'domain' else NodeType.IP)
attributes = {
'count': len(targets),
'nodes': targets,
'node_type': node_type,
'source_provider': provider_name,
'discovery_depth': current_depth,
'threshold_exceeded': self.config.large_entity_threshold,
}
description = f'Large entity created due to {len(targets)} results from {provider_name}'
self.graph.add_node(entity_id, NodeType.LARGE_ENTITY, attributes=attributes, description=description)
if results:
rel_type = results[0][2]
self.graph.add_edge(source, entity_id, rel_type, 0.9, provider_name,
{'large_entity_info': f'Contains {len(targets)} {node_type}s'})
self.logger.logger.warning(f"Large entity created: {entity_id} contains {len(targets)} targets from {provider_name}")
print(f"Created large entity {entity_id} for {len(targets)} {node_type}s from {provider_name}")
return set(targets)
def _collect_node_attributes(self, node_id: str, provider_name: str, rel_type: str,
target: str, raw_data: Dict[str, Any], attributes: Dict[str, Any]) -> None:
"""Collect and organize attributes for a node."""
self.logger.logger.debug(f"Collecting attributes for {node_id} from {provider_name}: {rel_type}")
if provider_name == 'dns':
record_type = raw_data.get('query_type', 'UNKNOWN')
value = raw_data.get('value', target)
dns_entry = f"{record_type}: {value}"
if dns_entry not in attributes.get('dns_records', []):
attributes.setdefault('dns_records', []).append(dns_entry)
elif provider_name == 'crtsh':
if rel_type == "san_certificate":
domain_certs = raw_data.get('domain_certificates', {})
if node_id in domain_certs:
cert_summary = domain_certs[node_id]
attributes['certificates'] = cert_summary
if target not in attributes.get('related_domains_san', []):
attributes.setdefault('related_domains_san', []).append(target)
elif provider_name == 'shodan':
shodan_attributes = attributes.setdefault('shodan', {})
for key, value in raw_data.items():
if key not in shodan_attributes or not shodan_attributes.get(key):
shodan_attributes[key] = value
if rel_type == "asn_membership":
attributes['asn'] = {
'id': target,
'description': raw_data.get('org', ''),
'isp': raw_data.get('isp', ''),
'country': raw_data.get('country', '')
}
record_type_name = rel_type
if record_type_name not in attributes:
attributes[record_type_name] = []
if isinstance(target, list):
attributes[record_type_name].extend(target)
else:
if target not in attributes[record_type_name]:
attributes[record_type_name].append(target)
def _log_target_processing_error(self, target: str, error: str) -> None: def _log_target_processing_error(self, target: str, error: str) -> None:
"""Log target processing errors for forensic trail.""" """Log target processing errors for forensic trail."""
self.logger.logger.error(f"Target processing failed for {target}: {error}") self.logger.logger.error(f"Target processing failed for {target}: {error}")
@@ -931,9 +848,10 @@ class Scanner:
def _calculate_progress(self) -> float: def _calculate_progress(self) -> float:
"""Calculate scan progress percentage based on task completion.""" """Calculate scan progress percentage based on task completion."""
if self.total_tasks_ever_enqueued == 0: total_tasks = self.indicators_completed + len(self.task_queue)
if total_tasks == 0:
return 0.0 return 0.0
return min(100.0, (self.indicators_completed / self.total_tasks_ever_enqueued) * 100) return min(100.0, (self.indicators_completed / total_tasks) * 100)
def get_graph_data(self) -> Dict[str, Any]: def get_graph_data(self) -> Dict[str, Any]:
"""Get current graph data for visualization.""" """Get current graph data for visualization."""

View File

@@ -5,11 +5,15 @@ import time
import uuid import uuid
import redis import redis
import pickle import pickle
from typing import Dict, Optional, Any from typing import Dict, Optional, Any, List
from core.scanner import Scanner from core.scanner import Scanner
from config import config from config import config
# WARNING: Using pickle can be a security risk if the data source is not trusted.
# In this case, we are only serializing/deserializing our own trusted Scanner objects,
# which is generally safe. Do not unpickle data from untrusted sources.
class SessionManager: class SessionManager:
""" """
Manages multiple scanner instances for concurrent user sessions using Redis. Manages multiple scanner instances for concurrent user sessions using Redis.

BIN
dump.rdb Normal file

Binary file not shown.

View File

@@ -3,15 +3,14 @@ Data provider modules for DNSRecon.
Contains implementations for various reconnaissance data sources. Contains implementations for various reconnaissance data sources.
""" """
from .base_provider import BaseProvider from .base_provider import BaseProvider, RateLimiter
from .crtsh_provider import CrtShProvider from .crtsh_provider import CrtShProvider
from .dns_provider import DNSProvider from .dns_provider import DNSProvider
from .shodan_provider import ShodanProvider from .shodan_provider import ShodanProvider
from core.rate_limiter import GlobalRateLimiter
__all__ = [ __all__ = [
'BaseProvider', 'BaseProvider',
'GlobalRateLimiter', 'RateLimiter',
'CrtShProvider', 'CrtShProvider',
'DNSProvider', 'DNSProvider',
'ShodanProvider' 'ShodanProvider'

View File

@@ -4,17 +4,49 @@ import time
import requests import requests
import threading import threading
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Dict, Any, Optional from typing import List, Dict, Any, Optional, Tuple
from core.logger import get_forensic_logger from core.logger import get_forensic_logger
from core.rate_limiter import GlobalRateLimiter
from core.provider_result import ProviderResult
class RateLimiter:
"""Simple rate limiter for API calls."""
def __init__(self, requests_per_minute: int):
"""
Initialize rate limiter.
Args:
requests_per_minute: Maximum requests allowed per minute
"""
self.requests_per_minute = requests_per_minute
self.min_interval = 60.0 / requests_per_minute
self.last_request_time = 0
def __getstate__(self):
"""RateLimiter is fully picklable, return full state."""
return self.__dict__.copy()
def __setstate__(self, state):
"""Restore RateLimiter state."""
self.__dict__.update(state)
def wait_if_needed(self) -> None:
"""Wait if necessary to respect rate limits."""
current_time = time.time()
time_since_last = current_time - self.last_request_time
if time_since_last < self.min_interval:
sleep_time = self.min_interval - time_since_last
time.sleep(sleep_time)
self.last_request_time = time.time()
class BaseProvider(ABC): class BaseProvider(ABC):
""" """
Abstract base class for all DNSRecon data providers. Abstract base class for all DNSRecon data providers.
Now supports session-specific configuration and returns standardized ProviderResult objects. Now supports session-specific configuration.
""" """
def __init__(self, name: str, rate_limit: int = 60, timeout: int = 30, session_config=None): def __init__(self, name: str, rate_limit: int = 60, timeout: int = 30, session_config=None):
@@ -36,9 +68,11 @@ class BaseProvider(ABC):
# Fallback to global config for backwards compatibility # Fallback to global config for backwards compatibility
from config import config as global_config from config import config as global_config
self.config = global_config self.config = global_config
actual_rate_limit = rate_limit
actual_timeout = timeout actual_timeout = timeout
self.name = name self.name = name
self.rate_limiter = RateLimiter(actual_rate_limit)
self.timeout = actual_timeout self.timeout = actual_timeout
self._local = threading.local() self._local = threading.local()
self.logger = get_forensic_logger() self.logger = get_forensic_logger()
@@ -102,7 +136,7 @@ class BaseProvider(ABC):
pass pass
@abstractmethod @abstractmethod
def query_domain(self, domain: str) -> ProviderResult: def query_domain(self, domain: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]:
""" """
Query the provider for information about a domain. Query the provider for information about a domain.
@@ -110,12 +144,12 @@ class BaseProvider(ABC):
domain: Domain to investigate domain: Domain to investigate
Returns: Returns:
ProviderResult containing standardized attributes and relationships List of tuples: (source_node, target_node, relationship_type, confidence, raw_data)
""" """
pass pass
@abstractmethod @abstractmethod
def query_ip(self, ip: str) -> ProviderResult: def query_ip(self, ip: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]:
""" """
Query the provider for information about an IP address. Query the provider for information about an IP address.
@@ -123,7 +157,7 @@ class BaseProvider(ABC):
ip: IP address to investigate ip: IP address to investigate
Returns: Returns:
ProviderResult containing standardized attributes and relationships List of tuples: (source_node, target_node, relationship_type, confidence, raw_data)
""" """
pass pass
@@ -138,6 +172,8 @@ class BaseProvider(ABC):
print(f"Request cancelled before start: {url}") print(f"Request cancelled before start: {url}")
return None return None
self.rate_limiter.wait_if_needed()
start_time = time.time() start_time = time.time()
response = None response = None
error = None error = None
@@ -261,5 +297,5 @@ class BaseProvider(ABC):
'failed_requests': self.failed_requests, 'failed_requests': self.failed_requests,
'success_rate': (self.successful_requests / self.total_requests * 100) if self.total_requests > 0 else 0, 'success_rate': (self.successful_requests / self.total_requests * 100) if self.total_requests > 0 else 0,
'relationships_found': self.total_relationships_found, 'relationships_found': self.total_relationships_found,
'rate_limit': self.config.get_rate_limit(self.name) 'rate_limit': self.rate_limiter.requests_per_minute
} }

View File

@@ -2,48 +2,59 @@
import json import json
import re import re
import os
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any, Set from typing import List, Dict, Any, Tuple, Set
from urllib.parse import quote
from datetime import datetime, timezone from datetime import datetime, timezone
import requests
# New dependency required for this provider
try:
import psycopg2
import psycopg2.extras
PSYCOPG2_AVAILABLE = True
except ImportError:
PSYCOPG2_AVAILABLE = False
from .base_provider import BaseProvider from .base_provider import BaseProvider
from core.provider_result import ProviderResult
from utils.helpers import _is_valid_domain from utils.helpers import _is_valid_domain
# We use requests only to raise the same exception type for compatibility with core retry logic
import requests
class CrtShProvider(BaseProvider): class CrtShProvider(BaseProvider):
""" """
Provider for querying crt.sh certificate transparency database. Provider for querying crt.sh certificate transparency database via its public PostgreSQL endpoint.
Now returns standardized ProviderResult objects with caching support. This version is designed to be a drop-in, high-performance replacement for the API-based provider.
It preserves the same caching and data processing logic.
""" """
def __init__(self, name=None, session_config=None): def __init__(self, name=None, session_config=None):
"""Initialize CrtSh provider with session-specific configuration.""" """Initialize CrtShDB provider with session-specific configuration."""
super().__init__( super().__init__(
name="crtsh", name="crtsh",
rate_limit=60, rate_limit=0, # No rate limit for direct DB access
timeout=15, timeout=60, # Increased timeout for potentially long DB queries
session_config=session_config session_config=session_config
) )
self.base_url = "https://crt.sh/" # Database connection details
self.db_host = "crt.sh"
self.db_port = 5432
self.db_name = "certwatch"
self.db_user = "guest"
self._stop_event = None self._stop_event = None
# Initialize cache directory # Initialize cache directory (same as original provider)
self.cache_dir = Path('cache') / 'crtsh' self.cache_dir = Path('cache') / 'crtsh'
self.cache_dir.mkdir(parents=True, exist_ok=True) self.cache_dir.mkdir(parents=True, exist_ok=True)
# Compile regex for date filtering for efficiency
self.date_pattern = re.compile(r'^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}')
def get_name(self) -> str: def get_name(self) -> str:
"""Return the provider name.""" """Return the provider name."""
return "crtsh" return "crtsh"
def get_display_name(self) -> str: def get_display_name(self) -> str:
"""Return the provider display name for the UI.""" """Return the provider display name for the UI."""
return "crt.sh" return "crt.sh (DB)"
def requires_api_key(self) -> bool: def requires_api_key(self) -> bool:
"""Return True if the provider requires an API key.""" """Return True if the provider requires an API key."""
@@ -54,19 +65,162 @@ class CrtShProvider(BaseProvider):
return {'domains': True, 'ips': False} return {'domains': True, 'ips': False}
def is_available(self) -> bool: def is_available(self) -> bool:
"""Check if the provider is configured to be used.""" """
Check if the provider can be used. Requires the psycopg2 library.
"""
if not PSYCOPG2_AVAILABLE:
self.logger.logger.warning("psycopg2 library not found. CrtShDBProvider is unavailable. "
"Please run 'pip install psycopg2-binary'.")
return False
return True return True
def _query_crtsh(self, domain: str) -> List[Dict[str, Any]]:
"""
Query the crt.sh PostgreSQL database for raw certificate data.
Raises exceptions for DB/network errors to allow core logic to retry.
"""
conn = None
certificates = []
# SQL Query to find all certificate IDs related to the domain (including subdomains),
# then retrieve comprehensive details for each certificate, mimicking the JSON API structure.
sql_query = """
WITH certificates_of_interest AS (
SELECT DISTINCT ci.certificate_id
FROM certificate_identity ci
WHERE ci.name_value ILIKE %(domain_wildcard)s OR ci.name_value = %(domain)s
)
SELECT
c.id,
c.serial_number,
c.not_before,
c.not_after,
(SELECT min(entry_timestamp) FROM ct_log_entry cle WHERE cle.certificate_id = c.id) as entry_timestamp,
ca.id as issuer_ca_id,
ca.name as issuer_name,
(SELECT array_to_string(array_agg(DISTINCT ci.name_value), E'\n') FROM certificate_identity ci WHERE ci.certificate_id = c.id) as name_value,
(SELECT name_value FROM certificate_identity ci WHERE ci.certificate_id = c.id AND ci.name_type = 'commonName' LIMIT 1) as common_name
FROM
certificate c
JOIN ca ON c.issuer_ca_id = ca.id
WHERE c.id IN (SELECT certificate_id FROM certificates_of_interest);
"""
try:
conn = psycopg2.connect(
dbname=self.db_name,
user=self.db_user,
host=self.db_host,
port=self.db_port,
connect_timeout=self.timeout
)
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cursor:
cursor.execute(sql_query, {'domain': domain, 'domain_wildcard': f'%.{domain}'})
results = cursor.fetchall()
certificates = [dict(row) for row in results]
self.logger.logger.info(f"crt.sh DB query for '{domain}' returned {len(certificates)} certificates.")
except psycopg2.Error as e:
self.logger.logger.error(f"PostgreSQL query failed for {domain}: {e}")
# Raise a RequestException to be compatible with the existing retry logic in the core application
raise requests.exceptions.RequestException(f"PostgreSQL query failed: {e}") from e
finally:
if conn:
conn.close()
return certificates
def query_domain(self, domain: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]:
"""
Query crt.sh for certificates containing the domain with caching support.
Properly raises exceptions for network errors to allow core logic retries.
"""
if not _is_valid_domain(domain):
return []
if self._stop_event and self._stop_event.is_set():
return []
cache_file = self._get_cache_file_path(domain)
cache_status = self._get_cache_status(cache_file)
certificates = []
try:
if cache_status == "fresh":
certificates = self._load_cached_certificates(cache_file)
self.logger.logger.info(f"Using cached data for {domain} ({len(certificates)} certificates)")
elif cache_status == "not_found":
# Fresh query from DB, create new cache
certificates = self._query_crtsh(domain)
if certificates:
self._create_cache_file(cache_file, domain, self._serialize_certs_for_cache(certificates))
else:
self.logger.logger.info(f"No certificates found for {domain}, not caching")
elif cache_status == "stale":
try:
new_certificates = self._query_crtsh(domain)
if new_certificates:
certificates = self._append_to_cache(cache_file, self._serialize_certs_for_cache(new_certificates))
else:
certificates = self._load_cached_certificates(cache_file)
except requests.exceptions.RequestException:
certificates = self._load_cached_certificates(cache_file)
if certificates:
self.logger.logger.warning(f"DB query failed for {domain}, using stale cache data.")
else:
raise
except requests.exceptions.RequestException as e:
# Re-raise so core logic can retry
self.logger.logger.error(f"DB query failed for {domain}: {e}")
raise e
except json.JSONDecodeError as e:
# JSON parsing errors from cache should also be handled
self.logger.logger.error(f"Failed to parse JSON from cache for {domain}: {e}")
raise e
if self._stop_event and self._stop_event.is_set():
return []
if not certificates:
return []
return self._process_certificates_to_relationships(domain, certificates)
def _serialize_certs_for_cache(self, certificates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Serialize certificate data for JSON caching, converting datetime objects to ISO strings.
"""
serialized_certs = []
for cert in certificates:
serialized_cert = cert.copy()
for key in ['not_before', 'not_after', 'entry_timestamp']:
if isinstance(serialized_cert.get(key), datetime):
# Ensure datetime is timezone-aware before converting
dt_obj = serialized_cert[key]
if dt_obj.tzinfo is None:
dt_obj = dt_obj.replace(tzinfo=timezone.utc)
serialized_cert[key] = dt_obj.isoformat()
serialized_certs.append(serialized_cert)
return serialized_certs
# --- All methods below are copied directly from the original CrtShProvider ---
# They are compatible because _query_crtsh returns data in the same format
# as the original _query_crtsh_api method. A small adjustment is made to
# _parse_certificate_date to handle datetime objects directly from the DB.
def _get_cache_file_path(self, domain: str) -> Path: def _get_cache_file_path(self, domain: str) -> Path:
"""Generate cache file path for a domain.""" """Generate cache file path for a domain."""
safe_domain = domain.replace('.', '_').replace('/', '_').replace('\\', '_') safe_domain = domain.replace('.', '_').replace('/', '_').replace('\\', '_')
return self.cache_dir / f"{safe_domain}.json" return self.cache_dir / f"{safe_domain}.json"
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."""
Check cache status for a domain.
Returns: 'not_found', 'fresh', or 'stale'
"""
if not cache_file_path.exists(): if not cache_file_path.exists():
return "not_found" return "not_found"
@@ -90,262 +244,131 @@ class CrtShProvider(BaseProvider):
except (json.JSONDecodeError, ValueError, KeyError) as e: except (json.JSONDecodeError, ValueError, KeyError) as e:
self.logger.logger.warning(f"Invalid cache file format for {cache_file_path}: {e}") self.logger.logger.warning(f"Invalid cache file format for {cache_file_path}: {e}")
return "stale" return "stale"
def query_domain(self, domain: str) -> ProviderResult: def _load_cached_certificates(self, cache_file_path: Path) -> List[Dict[str, Any]]:
""" """Load certificates from cache file."""
Query crt.sh for certificates containing the domain with caching support.
Args:
domain: Domain to investigate
Returns:
ProviderResult containing discovered relationships and attributes
"""
if not _is_valid_domain(domain):
return ProviderResult()
if self._stop_event and self._stop_event.is_set():
return ProviderResult()
cache_file = self._get_cache_file_path(domain)
cache_status = self._get_cache_status(cache_file)
processed_certificates = []
result = ProviderResult()
try:
if cache_status == "fresh":
result = self._load_from_cache(cache_file)
self.logger.logger.info(f"Using cached crt.sh data for {domain}")
else: # "stale" or "not_found"
raw_certificates = self._query_crtsh_api(domain)
if self._stop_event and self._stop_event.is_set():
return ProviderResult()
# Process raw data into the application's expected format
current_processed_certs = [self._extract_certificate_metadata(cert) for cert in raw_certificates]
if cache_status == "stale":
# Load existing and append new processed certs
existing_result = self._load_from_cache(cache_file)
result = self._merge_results(existing_result, current_processed_certs, domain)
self.logger.logger.info(f"Refreshed and merged cache for {domain}")
else: # "not_found"
# Create new result from processed certs
result = self._process_certificates_to_result(domain, raw_certificates)
self.logger.logger.info(f"Created fresh result for {domain} ({result.get_relationship_count()} relationships)")
# Save the result to cache
self._save_result_to_cache(cache_file, result, domain)
except requests.exceptions.RequestException as e:
self.logger.logger.error(f"API 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:
raise e # Re-raise if there's no cache to fall back on
return result
def query_ip(self, ip: str) -> ProviderResult:
"""
Query crt.sh for certificates containing the IP address.
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()
def _load_from_cache(self, cache_file_path: Path) -> ProviderResult:
"""Load processed crt.sh data from a cache file."""
try: try:
with open(cache_file_path, 'r') as f: with open(cache_file_path, 'r') as f:
cache_content = json.load(f) cache_data = json.load(f)
return cache_data.get('certificates', [])
result = ProviderResult()
# Reconstruct relationships
for rel_data in cache_content.get("relationships", []):
result.add_relationship(
source_node=rel_data["source_node"],
target_node=rel_data["target_node"],
relationship_type=rel_data["relationship_type"],
provider=rel_data["provider"],
confidence=rel_data["confidence"],
raw_data=rel_data.get("raw_data", {})
)
# Reconstruct attributes
for attr_data in cache_content.get("attributes", []):
result.add_attribute(
target_node=attr_data["target_node"],
name=attr_data["name"],
value=attr_data["value"],
attr_type=attr_data["type"],
provider=attr_data["provider"],
confidence=attr_data["confidence"],
metadata=attr_data.get("metadata", {})
)
return result
except (json.JSONDecodeError, FileNotFoundError, KeyError) as e: except (json.JSONDecodeError, FileNotFoundError, KeyError) as e:
self.logger.logger.error(f"Failed to load cached certificates from {cache_file_path}: {e}") self.logger.logger.error(f"Failed to load cached certificates from {cache_file_path}: {e}")
return ProviderResult() return []
def _save_result_to_cache(self, cache_file_path: Path, result: ProviderResult, domain: str) -> None: def _create_cache_file(self, cache_file_path: Path, domain: str, certificates: List[Dict[str, Any]]) -> None:
"""Save processed crt.sh result to a cache file.""" """Create new cache file with certificates."""
try: try:
cache_data = { cache_data = {
"domain": domain, "domain": domain,
"first_cached": datetime.now(timezone.utc).isoformat(),
"last_upstream_query": datetime.now(timezone.utc).isoformat(), "last_upstream_query": datetime.now(timezone.utc).isoformat(),
"relationships": [ "upstream_query_count": 1,
{ "certificates": certificates
"source_node": rel.source_node,
"target_node": rel.target_node,
"relationship_type": rel.relationship_type,
"confidence": rel.confidence,
"provider": rel.provider,
"raw_data": rel.raw_data
} for rel in result.relationships
],
"attributes": [
{
"target_node": attr.target_node,
"name": attr.name,
"value": attr.value,
"type": attr.type,
"provider": attr.provider,
"confidence": attr.confidence,
"metadata": attr.metadata
} 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: with open(cache_file_path, 'w') as f:
json.dump(cache_data, f, separators=(',', ':'), default=str) json.dump(cache_data, f, separators=(',', ':'))
self.logger.logger.info(f"Created cache file for {domain} with {len(certificates)} certificates")
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 create cache file for {domain}: {e}")
def _merge_results(self, existing_result: ProviderResult, new_certificates: List[Dict[str, Any]], domain: str) -> ProviderResult: def _append_to_cache(self, cache_file_path: Path, new_certificates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Merge new certificate data with existing cached result.""" """Append new certificates to existing cache and return all certificates."""
# Create a fresh result from the new certificates try:
new_result = self._process_certificates_to_result(domain, new_certificates) with open(cache_file_path, 'r') as f:
cache_data = json.load(f)
# Simple merge strategy: combine all relationships and attributes
# In practice, you might want more sophisticated deduplication existing_ids = {cert.get('id') for cert in cache_data.get('certificates', [])}
merged_result = ProviderResult() added_count = 0
for cert in new_certificates:
# Add existing relationships and attributes cert_id = cert.get('id')
merged_result.relationships.extend(existing_result.relationships) if cert_id and cert_id not in existing_ids:
merged_result.attributes.extend(existing_result.attributes) cache_data['certificates'].append(cert)
existing_ids.add(cert_id)
# Add new relationships and attributes added_count += 1
merged_result.relationships.extend(new_result.relationships)
merged_result.attributes.extend(new_result.attributes) cache_data['last_upstream_query'] = datetime.now(timezone.utc).isoformat()
cache_data['upstream_query_count'] = cache_data.get('upstream_query_count', 0) + 1
return merged_result
with open(cache_file_path, 'w') as f:
def _query_crtsh_api(self, domain: str) -> List[Dict[str, Any]]: json.dump(cache_data, f, separators=(',', ':'))
"""Query crt.sh API for raw certificate data."""
url = f"{self.base_url}?q={quote(domain)}&output=json" total_certs = len(cache_data['certificates'])
response = self.make_request(url, target_indicator=domain) self.logger.logger.info(f"Appended {added_count} new certificates to cache. Total: {total_certs}")
return cache_data['certificates']
if not response or response.status_code != 200: except Exception as e:
raise requests.exceptions.RequestException(f"crt.sh API returned status {response.status_code if response else 'None'}") self.logger.logger.warning(f"Failed to append to cache: {e}")
return new_certificates
certificates = response.json()
if not certificates: def _parse_issuer_organization(self, issuer_dn: str) -> str:
return [] """Parse the issuer Distinguished Name to extract just the organization name."""
if not issuer_dn: return issuer_dn
return certificates try:
components = [comp.strip() for comp in issuer_dn.split(',')]
def _process_certificates_to_result(self, domain: str, certificates: List[Dict[str, Any]]) -> ProviderResult: for component in components:
if component.startswith('O='):
org_name = component[2:].strip()
if org_name.startswith('"') and org_name.endswith('"'):
org_name = org_name[1:-1]
return org_name
return issuer_dn
except Exception as e:
self.logger.logger.debug(f"Failed to parse issuer DN '{issuer_dn}': {e}")
return issuer_dn
def _parse_certificate_date(self, date_input: Any) -> datetime:
""" """
Process certificates to create ProviderResult with relationships and attributes. Parse certificate date from various formats (string from cache, datetime from DB).
""" """
result = ProviderResult() if isinstance(date_input, datetime):
# If it's already a datetime object from the DB, just ensure it's UTC
if date_input.tzinfo is None:
return date_input.replace(tzinfo=timezone.utc)
return date_input
if self._stop_event and self._stop_event.is_set(): date_string = str(date_input)
print(f"CrtSh processing cancelled before processing for domain: {domain}") if not date_string:
return result raise ValueError("Empty date string")
all_discovered_domains = set() try:
if 'Z' in date_string:
return datetime.fromisoformat(date_string.replace('Z', '+00:00'))
# Handle standard ISO format with or without timezone
dt = datetime.fromisoformat(date_string)
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
except ValueError as e:
try:
# Fallback for other formats
return datetime.strptime(date_string[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
except Exception:
raise ValueError(f"Unable to parse date: {date_string}") from e
for i, cert_data in enumerate(certificates): def _is_cert_valid(self, cert_data: Dict[str, Any]) -> bool:
if i % 5 == 0 and self._stop_event and self._stop_event.is_set(): """Check if a certificate is currently valid based on its expiry date."""
print(f"CrtSh processing cancelled at certificate {i} for domain: {domain}") try:
break not_after_str = cert_data.get('not_after')
if not not_after_str: return False
cert_domains = self._extract_domains_from_certificate(cert_data) not_after_date = self._parse_certificate_date(not_after_str)
all_discovered_domains.update(cert_domains) not_before_str = cert_data.get('not_before')
now = datetime.now(timezone.utc)
is_not_expired = not_after_date > now
for cert_domain in cert_domains: if not_before_str:
if not _is_valid_domain(cert_domain): not_before_date = self._parse_certificate_date(not_before_str)
continue is_not_before_valid = not_before_date <= now
return is_not_expired and is_not_before_valid
for key, value in self._extract_certificate_metadata(cert_data).items(): return is_not_expired
if value is not None: except Exception as e:
result.add_attribute( self.logger.logger.debug(f"Certificate validity check failed: {e}")
target_node=cert_domain, return False
name=f"cert_{key}",
value=value,
attr_type='certificate_data',
provider=self.name,
confidence=0.9
)
if self._stop_event and self._stop_event.is_set():
print(f"CrtSh query cancelled before relationship creation for domain: {domain}")
return result
for i, discovered_domain in enumerate(all_discovered_domains):
if discovered_domain == domain:
continue
if i % 10 == 0 and self._stop_event and self._stop_event.is_set():
print(f"CrtSh relationship creation cancelled for domain: {domain}")
break
if not _is_valid_domain(discovered_domain):
continue
confidence = self._calculate_domain_relationship_confidence(
domain, discovered_domain, [], all_discovered_domains
)
result.add_relationship(
source_node=domain,
target_node=discovered_domain,
relationship_type='san_certificate',
provider=self.name,
confidence=confidence,
raw_data={'relationship_type': 'certificate_discovery'}
)
self.log_relationship_discovery(
source_node=domain,
target_node=discovered_domain,
relationship_type='san_certificate',
confidence_score=confidence,
raw_data={'relationship_type': 'certificate_discovery'},
discovery_method="certificate_transparency_analysis"
)
return result
def _extract_certificate_metadata(self, cert_data: Dict[str, Any]) -> Dict[str, Any]: def _extract_certificate_metadata(self, cert_data: Dict[str, Any]) -> Dict[str, Any]:
"""Extract comprehensive metadata from certificate data.""" # This method works as-is.
raw_issuer_name = cert_data.get('issuer_name', '') raw_issuer_name = cert_data.get('issuer_name', '')
parsed_issuer_name = self._parse_issuer_organization(raw_issuer_name) parsed_issuer_name = self._parse_issuer_organization(raw_issuer_name)
metadata = { metadata = {
'certificate_id': cert_data.get('id'), 'certificate_id': cert_data.get('id'),
'serial_number': cert_data.get('serial_number'), 'serial_number': cert_data.get('serial_number'),
@@ -355,286 +378,136 @@ class CrtShProvider(BaseProvider):
'not_before': cert_data.get('not_before'), 'not_before': cert_data.get('not_before'),
'not_after': cert_data.get('not_after'), 'not_after': cert_data.get('not_after'),
'entry_timestamp': cert_data.get('entry_timestamp'), 'entry_timestamp': cert_data.get('entry_timestamp'),
'source': 'crt.sh' 'source': 'crt.sh (DB)'
} }
try: try:
if metadata['not_before'] and metadata['not_after']: if metadata['not_before'] and metadata['not_after']:
not_before = self._parse_certificate_date(metadata['not_before']) not_before = self._parse_certificate_date(metadata['not_before'])
not_after = self._parse_certificate_date(metadata['not_after']) not_after = self._parse_certificate_date(metadata['not_after'])
metadata['validity_period_days'] = (not_after - not_before).days metadata['validity_period_days'] = (not_after - not_before).days
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
metadata['not_before'] = not_before.strftime('%Y-%m-%d %H:%M:%S UTC') metadata['not_before'] = not_before.strftime('%Y-%m-%d %H:%M:%S UTC')
metadata['not_after'] = not_after.strftime('%Y-%m-%d %H:%M:%S UTC') metadata['not_after'] = not_after.strftime('%Y-%m-%d %H:%M:%S UTC')
except Exception as e: except Exception as e:
self.logger.logger.debug(f"Error computing certificate metadata: {e}") self.logger.logger.debug(f"Error computing certificate metadata: {e}")
metadata['is_currently_valid'] = False metadata['is_currently_valid'] = False
metadata['expires_soon'] = False metadata['expires_soon'] = False
return metadata return metadata
def _parse_issuer_organization(self, issuer_dn: str) -> str: def _process_certificates_to_relationships(self, domain: str, certificates: List[Dict[str, Any]]) -> List[Tuple[str, str, str, float, Dict[str, Any]]]:
"""Parse the issuer Distinguished Name to extract just the organization name.""" # This method works as-is.
if not issuer_dn: relationships = []
return issuer_dn if self._stop_event and self._stop_event.is_set(): return []
domain_certificates = {}
try: all_discovered_domains = set()
components = [comp.strip() for comp in issuer_dn.split(',')] for i, cert_data in enumerate(certificates):
if i % 5 == 0 and self._stop_event and self._stop_event.is_set(): break
for component in components: cert_metadata = self._extract_certificate_metadata(cert_data)
if component.startswith('O='): cert_domains = self._extract_domains_from_certificate(cert_data)
org_name = component[2:].strip() all_discovered_domains.update(cert_domains)
if org_name.startswith('"') and org_name.endswith('"'): for cert_domain in cert_domains:
org_name = org_name[1:-1] if not _is_valid_domain(cert_domain): continue
return org_name if cert_domain not in domain_certificates:
domain_certificates[cert_domain] = []
return issuer_dn domain_certificates[cert_domain].append(cert_metadata)
if self._stop_event and self._stop_event.is_set(): return []
except Exception as e: for i, discovered_domain in enumerate(all_discovered_domains):
self.logger.logger.debug(f"Failed to parse issuer DN '{issuer_dn}': {e}") if discovered_domain == domain: continue
return issuer_dn if i % 10 == 0 and self._stop_event and self._stop_event.is_set(): break
if not _is_valid_domain(discovered_domain): continue
def _parse_certificate_date(self, date_string: str) -> datetime: query_domain_certs = domain_certificates.get(domain, [])
"""Parse certificate date from crt.sh format.""" discovered_domain_certs = domain_certificates.get(discovered_domain, [])
if not date_string: shared_certificates = self._find_shared_certificates(query_domain_certs, discovered_domain_certs)
raise ValueError("Empty date string") confidence = self._calculate_domain_relationship_confidence(
domain, discovered_domain, shared_certificates, all_discovered_domains
try: )
if date_string.endswith('Z'): relationship_raw_data = {
return datetime.fromisoformat(date_string[:-1]).replace(tzinfo=timezone.utc) 'relationship_type': 'certificate_discovery',
elif '+' in date_string or date_string.endswith('UTC'): 'shared_certificates': shared_certificates,
date_string = date_string.replace('UTC', '').strip() 'total_shared_certs': len(shared_certificates),
if '+' in date_string: 'discovery_context': self._determine_relationship_context(discovered_domain, domain),
date_string = date_string.split('+')[0] 'domain_certificates': {
return datetime.fromisoformat(date_string).replace(tzinfo=timezone.utc) domain: self._summarize_certificates(query_domain_certs),
else: discovered_domain: self._summarize_certificates(discovered_domain_certs)
return datetime.fromisoformat(date_string).replace(tzinfo=timezone.utc) }
except Exception as e: }
try: relationships.append((
return datetime.strptime(date_string[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) domain, discovered_domain, 'san_certificate', confidence, relationship_raw_data
except Exception: ))
raise ValueError(f"Unable to parse date: {date_string}") from e self.log_relationship_discovery(
source_node=domain, target_node=discovered_domain, relationship_type='san_certificate',
def _is_cert_valid(self, cert_data: Dict[str, Any]) -> bool: confidence_score=confidence, raw_data=relationship_raw_data,
"""Check if a certificate is currently valid based on its expiry date.""" discovery_method="certificate_transparency_analysis"
try: )
not_after_str = cert_data.get('not_after') return relationships
if not not_after_str:
return False # --- All remaining helper methods are identical to the original and fully compatible ---
# They are included here for completeness.
not_after_date = self._parse_certificate_date(not_after_str)
not_before_str = cert_data.get('not_before')
now = datetime.now(timezone.utc)
is_not_expired = not_after_date > now
if not_before_str:
not_before_date = self._parse_certificate_date(not_before_str)
is_not_before_valid = not_before_date <= now
return is_not_expired and is_not_before_valid
return is_not_expired
except Exception as e:
self.logger.logger.debug(f"Certificate validity check failed: {e}")
return False
def _extract_domains_from_certificate(self, cert_data: Dict[str, Any]) -> Set[str]:
"""Extract all domains from certificate data."""
domains = set()
# Extract from common name
common_name = cert_data.get('common_name', '')
if common_name:
cleaned_cn = self._clean_domain_name(common_name)
if cleaned_cn:
domains.update(cleaned_cn)
# Extract from name_value field (contains SANs)
name_value = cert_data.get('name_value', '')
if name_value:
for line in name_value.split('\n'):
cleaned_domains = self._clean_domain_name(line.strip())
if cleaned_domains:
domains.update(cleaned_domains)
return domains
def _clean_domain_name(self, domain_name: str) -> List[str]:
"""Clean and normalize domain name from certificate data."""
if not domain_name:
return []
domain = domain_name.strip().lower()
if domain.startswith(('http://', 'https://')):
domain = domain.split('://', 1)[1]
if '/' in domain:
domain = domain.split('/', 1)[0]
if ':' in domain and not domain.count(':') > 1:
domain = domain.split(':', 1)[0]
cleaned_domains = []
if domain.startswith('*.'):
cleaned_domains.append(domain)
cleaned_domains.append(domain[2:])
else:
cleaned_domains.append(domain)
final_domains = []
for d in cleaned_domains:
d = re.sub(r'[^\w\-\.]', '', d)
if d and not d.startswith(('.', '-')) and not d.endswith(('.', '-')):
final_domains.append(d)
return [d for d in final_domains if _is_valid_domain(d)]
def _find_shared_certificates(self, certs1: List[Dict[str, Any]], certs2: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def _find_shared_certificates(self, certs1: List[Dict[str, Any]], certs2: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Find certificates that are shared between two domain certificate lists.""" cert1_ids = {cert.get('certificate_id') for cert in certs1 if cert.get('certificate_id')}
shared = [] return [cert for cert in certs2 if cert.get('certificate_id') in cert1_ids]
cert1_ids = set()
for cert in certs1:
cert_id = cert.get('certificate_id')
if cert_id and isinstance(cert_id, (int, str, float, bool, tuple)):
cert1_ids.add(cert_id)
for cert in certs2:
cert_id = cert.get('certificate_id')
if cert_id and isinstance(cert_id, (int, str, float, bool, tuple)):
if cert_id in cert1_ids:
shared.append(cert)
return shared
def _summarize_certificates(self, certificates: List[Dict[str, Any]]) -> Dict[str, Any]: def _summarize_certificates(self, certificates: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Create a summary of certificates for a domain.""" if not certificates: return {'total_certificates': 0, 'valid_certificates': 0, 'expired_certificates': 0, 'expires_soon_count': 0, 'unique_issuers': [], 'latest_certificate': None, 'has_valid_cert': False}
if not certificates:
return {
'total_certificates': 0,
'valid_certificates': 0,
'expired_certificates': 0,
'expires_soon_count': 0,
'unique_issuers': [],
'latest_certificate': None,
'has_valid_cert': False,
'certificate_details': []
}
valid_count = sum(1 for cert in certificates if cert.get('is_currently_valid')) valid_count = sum(1 for cert in certificates if cert.get('is_currently_valid'))
expired_count = len(certificates) - valid_count
expires_soon_count = sum(1 for cert in certificates if cert.get('expires_soon')) expires_soon_count = sum(1 for cert in certificates if cert.get('expires_soon'))
unique_issuers = list(set(cert.get('issuer_name') for cert in certificates if cert.get('issuer_name'))) unique_issuers = list(set(cert.get('issuer_name') for cert in certificates if cert.get('issuer_name')))
latest_cert, latest_date = None, None
# Find the most recent certificate
latest_cert = None
latest_date = None
for cert in certificates: for cert in certificates:
try: try:
if cert.get('not_before'): if cert.get('not_before'):
cert_date = self._parse_certificate_date(cert['not_before']) cert_date = self._parse_certificate_date(cert['not_before'])
if latest_date is None or cert_date > latest_date: if latest_date is None or cert_date > latest_date:
latest_date = cert_date latest_date, latest_cert = cert_date, cert
latest_cert = cert except Exception: continue
except Exception: return {'total_certificates': len(certificates), 'valid_certificates': valid_count, 'expired_certificates': len(certificates) - valid_count, 'expires_soon_count': expires_soon_count, 'unique_issuers': unique_issuers, 'latest_certificate': latest_cert, 'has_valid_cert': valid_count > 0, 'certificate_details': certificates}
continue
# Sort certificates by date for better display (newest first)
sorted_certificates = sorted(
certificates,
key=lambda c: self._get_certificate_sort_date(c),
reverse=True
)
return {
'total_certificates': len(certificates),
'valid_certificates': valid_count,
'expired_certificates': expired_count,
'expires_soon_count': expires_soon_count,
'unique_issuers': unique_issuers,
'latest_certificate': latest_cert,
'has_valid_cert': valid_count > 0,
'certificate_details': sorted_certificates
}
def _get_certificate_sort_date(self, cert: Dict[str, Any]) -> datetime: def _calculate_domain_relationship_confidence(self, domain1: str, domain2: str, shared_certificates: List[Dict[str, Any]], all_discovered_domains: Set[str]) -> float:
"""Get a sortable date from certificate data for chronological ordering.""" base_confidence, context_bonus, shared_bonus, validity_bonus, issuer_bonus = 0.9, 0.0, 0.0, 0.0, 0.0
try:
if cert.get('not_before'):
return self._parse_certificate_date(cert['not_before'])
if cert.get('entry_timestamp'):
return self._parse_certificate_date(cert['entry_timestamp'])
return datetime(1970, 1, 1, tzinfo=timezone.utc)
except Exception:
return datetime(1970, 1, 1, tzinfo=timezone.utc)
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) relationship_context = self._determine_relationship_context(domain2, domain1)
if relationship_context == 'subdomain': context_bonus = 0.1
if relationship_context == 'exact_match': elif relationship_context == 'parent_domain': context_bonus = 0.05
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
# Adjust confidence based on shared certificates
if shared_certificates:
shared_count = len(shared_certificates)
if shared_count >= 3:
shared_bonus = 0.1
elif shared_count >= 2:
shared_bonus = 0.05
else:
shared_bonus = 0.02
valid_shared = sum(1 for cert in shared_certificates if cert.get('is_currently_valid'))
if valid_shared > 0:
validity_bonus = 0.05
else:
validity_bonus = 0.0
else:
shared_bonus = 0.0
validity_bonus = 0.0
# Adjust confidence based on certificate issuer reputation
issuer_bonus = 0.0
if shared_certificates: if shared_certificates:
if len(shared_certificates) >= 3: shared_bonus = 0.1
elif len(shared_certificates) >= 2: shared_bonus = 0.05
else: shared_bonus = 0.02
if any(cert.get('is_currently_valid') for cert in shared_certificates): validity_bonus = 0.05
for cert in shared_certificates: for cert in shared_certificates:
issuer = cert.get('issuer_name', '').lower() if any(ca in cert.get('issuer_name', '').lower() for ca in ['let\'s encrypt', 'digicert', 'sectigo', 'globalsign']):
if any(trusted_ca in issuer for trusted_ca in ['let\'s encrypt', 'digicert', 'sectigo', 'globalsign']):
issuer_bonus = max(issuer_bonus, 0.03) issuer_bonus = max(issuer_bonus, 0.03)
break break
return max(0.1, min(1.0, base_confidence + context_bonus + shared_bonus + validity_bonus + issuer_bonus))
final_confidence = base_confidence + context_bonus + shared_bonus + validity_bonus + issuer_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.""" if cert_domain == query_domain: return 'exact_match'
if cert_domain == query_domain: if cert_domain.endswith(f'.{query_domain}'): return 'subdomain'
return 'exact_match' if query_domain.endswith(f'.{cert_domain}'): return 'parent_domain'
elif cert_domain.endswith(f'.{query_domain}'): return 'related_domain'
return 'subdomain'
elif query_domain.endswith(f'.{cert_domain}'): def query_ip(self, ip: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]:
return 'parent_domain' return []
else:
return 'related_domain' def _extract_domains_from_certificate(self, cert_data: Dict[str, Any]) -> Set[str]:
domains = set()
if cn := cert_data.get('common_name'):
if cleaned := self._clean_domain_name(cn):
domains.update(cleaned)
if nv := cert_data.get('name_value'):
for line in nv.split('\n'):
if cleaned := self._clean_domain_name(line.strip()):
domains.update(cleaned)
return domains
def _clean_domain_name(self, domain_name: str) -> List[str]:
if not domain_name: return []
domain = domain_name.strip().lower().split('://', 1)[-1].split('/', 1)[0]
if ':' in domain and not domain.count(':') > 1: domain = domain.split(':', 1)[0]
cleaned_domains = [domain, domain[2:]] if domain.startswith('*.') else [domain]
final_domains = []
for d in cleaned_domains:
d = re.sub(r'[^\w\-\.]', '', d)
if d and not d.startswith(('.', '-')) and not d.endswith(('.', '-')):
final_domains.append(d)
return [d for d in final_domains if _is_valid_domain(d)]

View File

@@ -1,16 +1,15 @@
# dnsrecon/providers/dns_provider.py # dnsrecon/providers/dns_provider.py
from dns import resolver, reversename from dns import resolver, reversename
from typing import Dict from typing import List, Dict, Any, Tuple
from .base_provider import BaseProvider from .base_provider import BaseProvider
from core.provider_result import ProviderResult
from utils.helpers import _is_valid_ip, _is_valid_domain from utils.helpers import _is_valid_ip, _is_valid_domain
class DNSProvider(BaseProvider): 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. Now uses session-specific configuration.
""" """
def __init__(self, name=None, session_config=None): def __init__(self, name=None, session_config=None):
@@ -26,6 +25,7 @@ class DNSProvider(BaseProvider):
self.resolver = resolver.Resolver() self.resolver = resolver.Resolver()
self.resolver.timeout = 5 self.resolver.timeout = 5
self.resolver.lifetime = 10 self.resolver.lifetime = 10
#self.resolver.nameservers = ['127.0.0.1']
def get_name(self) -> str: def get_name(self) -> str:
"""Return the provider name.""" """Return the provider name."""
@@ -47,35 +47,31 @@ class DNSProvider(BaseProvider):
"""DNS is always available - no API key required.""" """DNS is always available - no API key required."""
return True return True
def query_domain(self, domain: str) -> ProviderResult: def query_domain(self, domain: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]:
""" """
Query DNS records for the domain to discover relationships and attributes. Query DNS records for the domain to discover relationships.
...
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 []
result = ProviderResult() relationships = []
# Query all record types # Query all record types
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) relationships.extend(self._query_record(domain, record_type))
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}")
except Exception as e: except Exception as e:
self.failed_requests += 1 self.failed_requests += 1
self.logger.logger.debug(f"{record_type} record query failed for {domain}: {e}") self.logger.logger.debug(f"{record_type} record query failed for {domain}: {e}")
# Optionally, you might want to re-raise other, more serious exceptions.
return result return relationships
def query_ip(self, ip: str) -> ProviderResult: def query_ip(self, ip: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]:
""" """
Query reverse DNS for the IP address. Query reverse DNS for the IP address.
@@ -83,12 +79,12 @@ class DNSProvider(BaseProvider):
ip: IP address to investigate ip: IP address to investigate
Returns: Returns:
ProviderResult containing discovered relationships and attributes List of relationships discovered from reverse DNS
""" """
if not _is_valid_ip(ip): if not _is_valid_ip(ip):
return ProviderResult() return []
result = ProviderResult() relationships = []
try: try:
# Perform reverse DNS lookup # Perform reverse DNS lookup
@@ -101,44 +97,27 @@ class DNSProvider(BaseProvider):
hostname = str(ptr_record).rstrip('.') hostname = str(ptr_record).rstrip('.')
if _is_valid_domain(hostname): if _is_valid_domain(hostname):
# Add the relationship raw_data = {
result.add_relationship( 'query_type': 'PTR',
source_node=ip, 'ip_address': ip,
target_node=hostname, 'hostname': hostname,
relationship_type='ptr_record', 'ttl': response.ttl
provider=self.name, }
confidence=0.8,
raw_data={
'query_type': 'PTR',
'ip_address': ip,
'hostname': hostname,
'ttl': response.ttl
}
)
# Add PTR record as attribute to the IP relationships.append((
result.add_attribute( ip,
target_node=ip, hostname,
name='ptr_record', 'ptr_record',
value=hostname, 0.8,
attr_type='dns_record', raw_data
provider=self.name, ))
confidence=0.8,
metadata={'ttl': response.ttl}
)
# Log the relationship discovery
self.log_relationship_discovery( self.log_relationship_discovery(
source_node=ip, source_node=ip,
target_node=hostname, target_node=hostname,
relationship_type='ptr_record', relationship_type='ptr_record',
confidence_score=0.8, confidence_score=0.8,
raw_data={ raw_data=raw_data,
'query_type': 'PTR',
'ip_address': ip,
'hostname': hostname,
'ttl': response.ttl
},
discovery_method="reverse_dns_lookup" discovery_method="reverse_dns_lookup"
) )
@@ -151,24 +130,18 @@ class DNSProvider(BaseProvider):
# Re-raise the exception so the scanner can handle the failure # Re-raise the exception so the scanner can handle the failure
raise e raise e
return result return relationships
def _query_record(self, domain: str, record_type: str, result: ProviderResult) -> None: def _query_record(self, domain: str, record_type: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]:
""" """
Query a specific type of DNS record for the domain and add results to ProviderResult. Query a specific type of DNS record for the domain.
Args:
domain: Domain to query
record_type: DNS record type (A, AAAA, CNAME, etc.)
result: ProviderResult to populate
""" """
relationships = []
try: try:
self.total_requests += 1 self.total_requests += 1
response = self.resolver.resolve(domain, record_type) response = self.resolver.resolve(domain, record_type)
self.successful_requests += 1 self.successful_requests += 1
dns_records = []
for record in response: for record in response:
target = "" target = ""
if record_type in ['A', 'AAAA']: if record_type in ['A', 'AAAA']:
@@ -180,16 +153,12 @@ class DNSProvider(BaseProvider):
elif record_type == 'SOA': elif record_type == 'SOA':
target = str(record.mname).rstrip('.') target = str(record.mname).rstrip('.')
elif record_type in ['TXT']: elif record_type in ['TXT']:
# TXT records are treated as attributes, not relationships # TXT records are treated as metadata, not relationships.
txt_value = str(record).strip('"')
dns_records.append(f"TXT: {txt_value}")
continue continue
elif record_type == 'SRV': elif record_type == 'SRV':
target = str(record.target).rstrip('.') target = str(record.target).rstrip('.')
elif record_type == 'CAA': elif record_type == 'CAA':
caa_value = f"{record.flags} {record.tag.decode('utf-8')} \"{record.value.decode('utf-8')}\"" target = f"{record.flags} {record.tag.decode('utf-8')} \"{record.value.decode('utf-8')}\""
dns_records.append(f"CAA: {caa_value}")
continue
else: else:
target = str(record) target = str(record)
@@ -201,22 +170,16 @@ class DNSProvider(BaseProvider):
'ttl': response.ttl 'ttl': response.ttl
} }
relationship_type = f"{record_type.lower()}_record" relationship_type = f"{record_type.lower()}_record"
confidence = 0.8 # Standard confidence for DNS records confidence = 0.8 # Default confidence for DNS records
# Add relationship relationships.append((
result.add_relationship( domain,
source_node=domain, target,
target_node=target, relationship_type,
relationship_type=relationship_type, confidence,
provider=self.name, raw_data
confidence=confidence, ))
raw_data=raw_data
)
# Add DNS record as attribute to the source domain
dns_records.append(f"{record_type}: {target}")
# Log relationship discovery
self.log_relationship_discovery( self.log_relationship_discovery(
source_node=domain, source_node=domain,
target_node=target, target_node=target,
@@ -226,20 +189,10 @@ class DNSProvider(BaseProvider):
discovery_method=f"dns_{record_type.lower()}_record" discovery_method=f"dns_{record_type.lower()}_record"
) )
# Add DNS records as a consolidated attribute
if dns_records:
result.add_attribute(
target_node=domain,
name='dns_records',
value=dns_records,
attr_type='dns_record_list',
provider=self.name,
confidence=0.8,
metadata={'record_types': [record_type]}
)
except Exception as e: except Exception as e:
self.failed_requests += 1 self.failed_requests += 1
self.logger.logger.debug(f"{record_type} record query failed for {domain}: {e}") self.logger.logger.debug(f"{record_type} record query failed for {domain}: {e}")
# Re-raise the exception so the scanner can handle it # Re-raise the exception so the scanner can handle it
raise e raise e
return relationships

View File

@@ -1,20 +1,15 @@
# dnsrecon/providers/shodan_provider.py # dnsrecon/providers/shodan_provider.py
import json import json
from pathlib import Path from typing import List, Dict, Any, Tuple
from typing import Dict, Any
from datetime import datetime, timezone
import requests
from .base_provider import BaseProvider from .base_provider import BaseProvider
from core.provider_result import ProviderResult
from utils.helpers import _is_valid_ip, _is_valid_domain from utils.helpers import _is_valid_ip, _is_valid_domain
class ShodanProvider(BaseProvider): class ShodanProvider(BaseProvider):
""" """
Provider for querying Shodan API for IP address information. Provider for querying Shodan API for IP address and hostname information.
Now returns standardized ProviderResult objects with caching support. Now uses session-specific API keys.
""" """
def __init__(self, name=None, session_config=None): def __init__(self, name=None, session_config=None):
@@ -27,10 +22,6 @@ class ShodanProvider(BaseProvider):
) )
self.base_url = "https://api.shodan.io" self.base_url = "https://api.shodan.io"
self.api_key = self.config.get_api_key('shodan') self.api_key = self.config.get_api_key('shodan')
# Initialize cache directory
self.cache_dir = Path('cache') / 'shodan'
self.cache_dir.mkdir(parents=True, exist_ok=True)
def is_available(self) -> bool: def is_available(self) -> bool:
"""Check if Shodan provider is available (has valid API key in this session).""" """Check if Shodan provider is available (has valid API key in this session)."""
@@ -42,7 +33,7 @@ class ShodanProvider(BaseProvider):
def get_display_name(self) -> str: def get_display_name(self) -> str:
"""Return the provider display name for the UI.""" """Return the provider display name for the UI."""
return "Shodan" return "shodan"
def requires_api_key(self) -> bool: def requires_api_key(self) -> bool:
"""Return True if the provider requires an API key.""" """Return True if the provider requires an API key."""
@@ -50,234 +41,267 @@ class ShodanProvider(BaseProvider):
def get_eligibility(self) -> Dict[str, bool]: def get_eligibility(self) -> Dict[str, bool]:
"""Return a dictionary indicating if the provider can query domains and/or IPs.""" """Return a dictionary indicating if the provider can query domains and/or IPs."""
return {'domains': False, 'ips': True} return {'domains': True, 'ips': True}
def _get_cache_file_path(self, ip: str) -> Path: def query_domain(self, domain: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]:
"""Generate cache file path for an IP address.""" """
safe_ip = ip.replace('.', '_').replace(':', '_') Query Shodan for information about a domain.
return self.cache_dir / f"{safe_ip}.json" Uses Shodan's hostname search to find associated IPs.
def _get_cache_status(self, cache_file_path: Path) -> str:
"""
Check cache status for an IP.
Returns: 'not_found', 'fresh', or 'stale'
"""
if not cache_file_path.exists():
return "not_found"
try:
with open(cache_file_path, 'r') as f:
cache_data = json.load(f)
last_query_str = cache_data.get("last_upstream_query")
if not last_query_str:
return "stale"
last_query = datetime.fromisoformat(last_query_str.replace('Z', '+00:00'))
hours_since_query = (datetime.now(timezone.utc) - last_query).total_seconds() / 3600
cache_timeout = self.config.cache_timeout_hours
if hours_since_query < cache_timeout:
return "fresh"
else:
return "stale"
except (json.JSONDecodeError, ValueError, KeyError):
return "stale"
def query_domain(self, domain: str) -> ProviderResult:
"""
Domain queries are no longer supported for the Shodan provider.
Args: Args:
domain: Domain to investigate domain: Domain to investigate
Returns:
Empty ProviderResult
"""
return ProviderResult()
def query_ip(self, ip: str) -> ProviderResult: Returns:
List of relationships discovered from Shodan data
""" """
Query Shodan for information about an IP address, with caching of processed data. if not _is_valid_domain(domain) or not self.is_available():
return []
relationships = []
try:
# Search for hostname in Shodan
search_query = f"hostname:{domain}"
url = f"{self.base_url}/shodan/host/search"
params = {
'key': self.api_key,
'query': search_query,
'minify': True # Get minimal data to reduce bandwidth
}
response = self.make_request(url, method="GET", params=params, target_indicator=domain)
if not response or response.status_code != 200:
return []
data = response.json()
if 'matches' not in data:
return []
# Process search results
for match in data['matches']:
ip_address = match.get('ip_str')
hostnames = match.get('hostnames', [])
if ip_address and domain in hostnames:
raw_data = {
'ip_address': ip_address,
'hostnames': hostnames,
'country': match.get('location', {}).get('country_name', ''),
'city': match.get('location', {}).get('city', ''),
'isp': match.get('isp', ''),
'org': match.get('org', ''),
'ports': match.get('ports', []),
'last_update': match.get('last_update', '')
}
relationships.append((
domain,
ip_address,
'a_record', # Domain resolves to IP
0.8,
raw_data
))
self.log_relationship_discovery(
source_node=domain,
target_node=ip_address,
relationship_type='a_record',
confidence_score=0.8,
raw_data=raw_data,
discovery_method="shodan_hostname_search"
)
# Also create relationships to other hostnames on the same IP
for hostname in hostnames:
if hostname != domain and _is_valid_domain(hostname):
hostname_raw_data = {
'shared_ip': ip_address,
'all_hostnames': hostnames,
'discovery_context': 'shared_hosting'
}
relationships.append((
domain,
hostname,
'passive_dns', # Shared hosting relationship
0.6, # Lower confidence for shared hosting
hostname_raw_data
))
self.log_relationship_discovery(
source_node=domain,
target_node=hostname,
relationship_type='passive_dns',
confidence_score=0.6,
raw_data=hostname_raw_data,
discovery_method="shodan_shared_hosting"
)
except json.JSONDecodeError as e:
self.logger.logger.error(f"Failed to parse JSON response from Shodan: {e}")
return relationships
def query_ip(self, ip: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]:
"""
Query Shodan for information about an IP address.
Args: Args:
ip: IP address to investigate ip: IP address to investigate
Returns: Returns:
ProviderResult containing discovered relationships and attributes List of relationships discovered from Shodan IP data
""" """
if not _is_valid_ip(ip) or not self.is_available(): if not _is_valid_ip(ip) or not self.is_available():
return ProviderResult() return []
cache_file = self._get_cache_file_path(ip) relationships = []
cache_status = self._get_cache_status(cache_file)
result = ProviderResult()
try: try:
if cache_status == "fresh": # Query Shodan host information
result = self._load_from_cache(cache_file) url = f"{self.base_url}/shodan/host/{ip}"
self.logger.logger.info(f"Using cached Shodan data for {ip}") params = {'key': self.api_key}
else: # "stale" or "not_found"
url = f"{self.base_url}/shodan/host/{ip}"
params = {'key': self.api_key}
response = self.make_request(url, method="GET", params=params, target_indicator=ip)
if response and response.status_code == 200:
data = response.json()
# Process the data into ProviderResult BEFORE caching
result = self._process_shodan_data(ip, data)
self._save_to_cache(cache_file, result, data) # Save both result and raw data
elif cache_status == "stale":
# If API fails on a stale cache, use the old data
result = self._load_from_cache(cache_file)
except requests.exceptions.RequestException as e: response = self.make_request(url, method="GET", params=params, target_indicator=ip)
self.logger.logger.error(f"Shodan API query failed for {ip}: {e}")
if cache_status == "stale":
result = self._load_from_cache(cache_file)
return result
def _load_from_cache(self, cache_file_path: Path) -> ProviderResult: if not response or response.status_code != 200:
"""Load processed Shodan data from a cache file.""" return []
try:
with open(cache_file_path, 'r') as f:
cache_content = json.load(f)
result = ProviderResult()
# Reconstruct relationships
for rel_data in cache_content.get("relationships", []):
result.add_relationship(
source_node=rel_data["source_node"],
target_node=rel_data["target_node"],
relationship_type=rel_data["relationship_type"],
provider=rel_data["provider"],
confidence=rel_data["confidence"],
raw_data=rel_data.get("raw_data", {})
)
# Reconstruct attributes
for attr_data in cache_content.get("attributes", []):
result.add_attribute(
target_node=attr_data["target_node"],
name=attr_data["name"],
value=attr_data["value"],
attr_type=attr_data["type"],
provider=attr_data["provider"],
confidence=attr_data["confidence"],
metadata=attr_data.get("metadata", {})
)
return result
except (json.JSONDecodeError, FileNotFoundError, KeyError):
return ProviderResult()
def _save_to_cache(self, cache_file_path: Path, result: ProviderResult, raw_data: Dict[str, Any]) -> None: data = response.json()
"""Save processed Shodan data to a cache file."""
try:
cache_data = {
"last_upstream_query": datetime.now(timezone.utc).isoformat(),
"raw_data": raw_data, # Preserve original for forensic purposes
"relationships": [
{
"source_node": rel.source_node,
"target_node": rel.target_node,
"relationship_type": rel.relationship_type,
"confidence": rel.confidence,
"provider": rel.provider,
"raw_data": rel.raw_data
} for rel in result.relationships
],
"attributes": [
{
"target_node": attr.target_node,
"name": attr.name,
"value": attr.value,
"type": attr.type,
"provider": attr.provider,
"confidence": attr.confidence,
"metadata": attr.metadata
} for attr in result.attributes
]
}
with open(cache_file_path, 'w') as f:
json.dump(cache_data, f, separators=(',', ':'), default=str)
except Exception as e:
self.logger.logger.warning(f"Failed to save Shodan cache for {cache_file_path.name}: {e}")
def _process_shodan_data(self, ip: str, data: Dict[str, Any]) -> ProviderResult: # Extract hostname relationships
""" hostnames = data.get('hostnames', [])
Process Shodan data to extract relationships and attributes. for hostname in hostnames:
if _is_valid_domain(hostname):
Args: raw_data = {
ip: IP address queried 'ip_address': ip,
data: Raw Shodan response data 'hostname': hostname,
'country': data.get('country_name', ''),
Returns: 'city': data.get('city', ''),
ProviderResult with relationships and attributes 'isp': data.get('isp', ''),
""" 'org': data.get('org', ''),
result = ProviderResult() 'asn': data.get('asn', ''),
'ports': data.get('ports', []),
'last_update': data.get('last_update', ''),
'os': data.get('os', '')
}
relationships.append((
ip,
hostname,
'a_record', # IP resolves to hostname
0.8,
raw_data
))
self.log_relationship_discovery(
source_node=ip,
target_node=hostname,
relationship_type='a_record',
confidence_score=0.8,
raw_data=raw_data,
discovery_method="shodan_host_lookup"
)
# Extract ASN relationship if available
asn = data.get('asn')
if asn:
# Ensure the ASN starts with "AS"
if isinstance(asn, str) and asn.startswith('AS'):
asn_name = asn
asn_number = asn[2:]
else:
asn_name = f"AS{asn}"
asn_number = str(asn)
asn_raw_data = {
'ip_address': ip,
'asn': asn_number,
'isp': data.get('isp', ''),
'org': data.get('org', '')
}
relationships.append((
ip,
asn_name,
'asn_membership',
0.7,
asn_raw_data
))
for key, value in data.items():
if key == 'hostnames':
for hostname in value:
if _is_valid_domain(hostname):
result.add_relationship(
source_node=ip,
target_node=hostname,
relationship_type='a_record',
provider=self.name,
confidence=0.8,
raw_data=data
)
self.log_relationship_discovery(
source_node=ip,
target_node=hostname,
relationship_type='a_record',
confidence_score=0.8,
raw_data=data,
discovery_method="shodan_host_lookup"
)
elif key == 'asn':
asn_name = f"AS{value[2:]}" if isinstance(value, str) and value.startswith('AS') else f"AS{value}"
result.add_relationship(
source_node=ip,
target_node=asn_name,
relationship_type='asn_membership',
provider=self.name,
confidence=0.7,
raw_data=data
)
self.log_relationship_discovery( self.log_relationship_discovery(
source_node=ip, source_node=ip,
target_node=asn_name, target_node=asn_name,
relationship_type='asn_membership', relationship_type='asn_membership',
confidence_score=0.7, confidence_score=0.7,
raw_data=data, raw_data=asn_raw_data,
discovery_method="shodan_asn_lookup" discovery_method="shodan_asn_lookup"
) )
elif key == 'ports':
for port in value:
result.add_attribute(
target_node=ip,
name='open_port',
value=port,
attr_type='network_info',
provider=self.name,
confidence=0.9
)
elif isinstance(value, (str, int, float, bool)) and value is not None:
result.add_attribute(
target_node=ip,
name=f"shodan_{key}",
value=value,
attr_type='shodan_info',
provider=self.name,
confidence=0.9
)
return result except json.JSONDecodeError as e:
self.logger.logger.error(f"Failed to parse JSON response from Shodan: {e}")
return relationships
def search_by_organization(self, org_name: str) -> List[Dict[str, Any]]:
"""
Search Shodan for hosts belonging to a specific organization.
Args:
org_name: Organization name to search for
Returns:
List of host information dictionaries
"""
if not self.is_available():
return []
try:
search_query = f"org:\"{org_name}\""
url = f"{self.base_url}/shodan/host/search"
params = {
'key': self.api_key,
'query': search_query,
'minify': True
}
response = self.make_request(url, method="GET", params=params, target_indicator=org_name)
if response and response.status_code == 200:
data = response.json()
return data.get('matches', [])
except Exception as e:
self.logger.logger.error(f"Error searching Shodan by organization {org_name}: {e}")
return []
def get_host_services(self, ip: str) -> List[Dict[str, Any]]:
"""
Get service information for a specific IP address.
Args:
ip: IP address to query
Returns:
List of service information dictionaries
"""
if not _is_valid_ip(ip) or not self.is_available():
return []
try:
url = f"{self.base_url}/shodan/host/{ip}"
params = {'key': self.api_key}
response = self.make_request(url, method="GET", params=params, target_indicator=ip)
if response and response.status_code == 200:
data = response.json()
return data.get('data', []) # Service banners
except Exception as e:
self.logger.logger.error(f"Error getting Shodan services for IP {ip}: {e}")
return []

View File

@@ -7,4 +7,5 @@ urllib3>=2.0.0
dnspython>=2.4.2 dnspython>=2.4.2
gunicorn gunicorn
redis redis
python-dotenv python-dotenv
psycopg2-binary

File diff suppressed because it is too large Load Diff

View File

@@ -1,58 +1,7 @@
/** /**
* Graph visualization module for DNSRecon * Graph visualization module for DNSRecon
* Handles network graph rendering using vis.js with proper large entity node hiding * Handles network graph rendering using vis.js
* UPDATED: Now compatible with a strictly flat, unified data model for attributes.
*/ */
const contextMenuCSS = `
.graph-context-menu {
position: fixed;
z-index: 1000;
background: linear-gradient(135deg, #2a2a2a 0%, #1e1e1e 100%);
border: 1px solid #444;
border-radius: 6px;
box-shadow: 0 8px 25px rgba(0,0,0,0.6);
display: none;
font-family: 'Roboto Mono', monospace;
font-size: 0.9rem;
color: #c7c7c7;
min-width: 180px;
overflow: hidden;
}
.graph-context-menu ul {
list-style: none;
padding: 0.5rem 0;
margin: 0;
}
.graph-context-menu ul li {
padding: 0.75rem 1rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
}
.graph-context-menu ul li:hover {
background: linear-gradient(135deg, #3a3a3a 0%, #2e2e2e 100%);
color: #00ff41;
}
.graph-context-menu .menu-icon {
font-size: 0.9rem;
width: 1.2rem;
text-align: center;
}
.graph-context-menu ul li:first-child {
border-top: none;
}
.graph-context-menu ul li:last-child {
border-bottom: none;
}
`;
class GraphManager { class GraphManager {
constructor(containerId) { constructor(containerId) {
@@ -63,13 +12,6 @@ class GraphManager {
this.isInitialized = false; this.isInitialized = false;
this.currentLayout = 'physics'; this.currentLayout = 'physics';
this.nodeInfoPopup = null; this.nodeInfoPopup = null;
this.contextMenu = null;
this.history = [];
this.filterPanel = null;
this.initialTargetIds = new Set();
// Track large entity members for proper hiding
this.largeEntityMembers = new Set();
this.isScanning = false;
this.options = { this.options = {
nodes: { nodes: {
@@ -173,14 +115,8 @@ class GraphManager {
randomSeed: 2 randomSeed: 2
} }
}; };
if (typeof document !== 'undefined') {
const style = document.createElement('style');
style.textContent = contextMenuCSS;
document.head.appendChild(style);
}
this.createNodeInfoPopup(); this.createNodeInfoPopup();
this.createContextMenu();
document.body.addEventListener('click', () => this.hideContextMenu());
} }
/** /**
@@ -192,30 +128,6 @@ class GraphManager {
this.nodeInfoPopup.style.display = 'none'; this.nodeInfoPopup.style.display = 'none';
document.body.appendChild(this.nodeInfoPopup); document.body.appendChild(this.nodeInfoPopup);
} }
/**
* Create context menu
*/
createContextMenu() {
// Remove existing context menu if it exists
const existing = document.getElementById('graph-context-menu');
if (existing) {
existing.remove();
}
this.contextMenu = document.createElement('div');
this.contextMenu.id = 'graph-context-menu';
this.contextMenu.className = 'graph-context-menu';
this.contextMenu.style.display = 'none';
// Prevent body click listener from firing when clicking the menu itself
this.contextMenu.addEventListener('click', (event) => {
event.stopPropagation();
});
document.body.appendChild(this.contextMenu);
console.log('Context menu created and added to body');
}
/** /**
* Initialize the network graph * Initialize the network graph
@@ -243,7 +155,6 @@ class GraphManager {
// Add graph controls // Add graph controls
this.addGraphControls(); this.addGraphControls();
this.addFilterPanel();
console.log('Graph initialized successfully'); console.log('Graph initialized successfully');
} catch (error) { } catch (error) {
@@ -262,8 +173,6 @@ class GraphManager {
<button class="graph-control-btn" id="graph-fit" title="Fit to Screen">[FIT]</button> <button class="graph-control-btn" id="graph-fit" title="Fit to Screen">[FIT]</button>
<button class="graph-control-btn" id="graph-physics" title="Toggle Physics">[PHYSICS]</button> <button class="graph-control-btn" id="graph-physics" title="Toggle Physics">[PHYSICS]</button>
<button class="graph-control-btn" id="graph-cluster" title="Cluster Nodes">[CLUSTER]</button> <button class="graph-control-btn" id="graph-cluster" title="Cluster Nodes">[CLUSTER]</button>
<button class="graph-control-btn" id="graph-unhide" title="Unhide All">[UNHIDE]</button>
<button class="graph-control-btn" id="graph-revert" title="Revert Last Action">[REVERT]</button>
`; `;
this.container.appendChild(controlsContainer); this.container.appendChild(controlsContainer);
@@ -272,14 +181,6 @@ class GraphManager {
document.getElementById('graph-fit').addEventListener('click', () => this.fitView()); document.getElementById('graph-fit').addEventListener('click', () => this.fitView());
document.getElementById('graph-physics').addEventListener('click', () => this.togglePhysics()); document.getElementById('graph-physics').addEventListener('click', () => this.togglePhysics());
document.getElementById('graph-cluster').addEventListener('click', () => this.toggleClustering()); document.getElementById('graph-cluster').addEventListener('click', () => this.toggleClustering());
document.getElementById('graph-unhide').addEventListener('click', () => this.unhideAll());
document.getElementById('graph-revert').addEventListener('click', () => this.revertLastAction());
}
addFilterPanel() {
this.filterPanel = document.createElement('div');
this.filterPanel.className = 'graph-filter-panel';
this.container.appendChild(this.filterPanel);
} }
/** /**
@@ -288,31 +189,8 @@ class GraphManager {
setupNetworkEvents() { setupNetworkEvents() {
if (!this.network) return; if (!this.network) return;
// FIXED: Right-click context menu
this.container.addEventListener('contextmenu', (event) => {
event.preventDefault();
console.log('Right-click detected at:', event.offsetX, event.offsetY);
// Get coordinates relative to the canvas
const pointer = {
x: event.offsetX,
y: event.offsetY
};
const nodeId = this.network.getNodeAt(pointer);
console.log('Node at pointer:', nodeId);
if (nodeId) {
// Pass the original client event for positioning
this.showContextMenu(nodeId, event);
} else {
this.hideContextMenu();
}
});
// Node click event with details // Node click event with details
this.network.on('click', (params) => { this.network.on('click', (params) => {
this.hideContextMenu();
if (params.nodes.length > 0) { if (params.nodes.length > 0) {
const nodeId = params.nodes[0]; const nodeId = params.nodes[0];
if (this.network.isCluster(nodeId)) { if (this.network.isCluster(nodeId)) {
@@ -338,6 +216,10 @@ class GraphManager {
} }
}); });
this.network.on('oncontext', (params) => {
params.event.preventDefault();
});
// Stabilization events with progress // Stabilization events with progress
this.network.on('stabilizationProgress', (params) => { this.network.on('stabilizationProgress', (params) => {
const progress = params.iterations / params.total; const progress = params.iterations / params.total;
@@ -353,13 +235,6 @@ class GraphManager {
console.log('Selected nodes:', params.nodes); console.log('Selected nodes:', params.nodes);
console.log('Selected edges:', params.edges); console.log('Selected edges:', params.edges);
}); });
// Click away to hide context menu
document.addEventListener('click', (e) => {
if (!this.contextMenu.contains(e.target)) {
this.hideContextMenu();
}
});
} }
/** /**
@@ -377,32 +252,21 @@ class GraphManager {
this.initialize(); this.initialize();
} }
this.largeEntityMembers.clear();
const largeEntityMap = new Map(); const largeEntityMap = new Map();
graphData.nodes.forEach(node => { graphData.nodes.forEach(node => {
if (node.type === 'large_entity' && node.attributes) { if (node.type === 'large_entity' && node.attributes && Array.isArray(node.attributes.nodes)) {
// UPDATED: Handle unified data model - look for 'nodes' attribute in the attributes list node.attributes.nodes.forEach(nodeId => {
const nodesAttribute = this.findAttributeByName(node.attributes, 'nodes'); largeEntityMap.set(nodeId, node.id);
if (nodesAttribute && Array.isArray(nodesAttribute.value)) { });
nodesAttribute.value.forEach(nodeId => {
largeEntityMap.set(nodeId, node.id);
this.largeEntityMembers.add(nodeId);
});
}
} }
}); });
const filteredNodes = graphData.nodes.filter(node => { const processedNodes = graphData.nodes.map(node => {
// Only include nodes that are NOT members of large entities, but always include the container itself const processed = this.processNode(node);
return !this.largeEntityMembers.has(node.id) || node.type === 'large_entity'; if (largeEntityMap.has(node.id)) {
}); processed.hidden = true;
}
console.log(`Filtered ${graphData.nodes.length - filteredNodes.length} large entity member nodes from visualization`); return processed;
// Process only the filtered nodes
const processedNodes = filteredNodes.map(node => {
return this.processNode(node);
}); });
const mergedEdges = {}; const mergedEdges = {};
@@ -446,10 +310,6 @@ class GraphManager {
// Update existing data // Update existing data
this.nodes.update(processedNodes); this.nodes.update(processedNodes);
this.edges.update(processedEdges); this.edges.update(processedEdges);
// After data is loaded, apply filters
this.updateFilterControls();
this.applyAllFilters();
// Highlight new additions briefly // Highlight new additions briefly
if (newNodes.length > 0 || newEdges.length > 0) { if (newNodes.length > 0 || newEdges.length > 0) {
@@ -462,8 +322,6 @@ class GraphManager {
} }
console.log(`Graph updated: ${processedNodes.length} nodes, ${processedEdges.length} edges (${newNodes.length} new nodes, ${newEdges.length} new edges)`); console.log(`Graph updated: ${processedNodes.length} nodes, ${processedEdges.length} edges (${newNodes.length} new nodes, ${newEdges.length} new edges)`);
console.log(`Large entity members hidden: ${this.largeEntityMembers.size}`);
} catch (error) { } catch (error) {
console.error('Failed to update graph:', error); console.error('Failed to update graph:', error);
this.showError('Failed to update visualization'); this.showError('Failed to update visualization');
@@ -471,21 +329,8 @@ class GraphManager {
} }
/** /**
* UPDATED: Helper method to find an attribute by name in the standardized attributes list * Process node data with styling and metadata
* @param {Array} attributes - List of StandardAttribute objects * @param {Object} node - Raw node data
* @param {string} name - Attribute name to find
* @returns {Object|null} The attribute object if found, null otherwise
*/
findAttributeByName(attributes, name) {
if (!Array.isArray(attributes)) {
return null;
}
return attributes.find(attr => attr.name === name) || null;
}
/**
* UPDATED: Process node data with styling and metadata for the flat data model
* @param {Object} node - Raw node data with standardized attributes
* @returns {Object} Processed node data * @returns {Object} Processed node data
*/ */
processNode(node) { processNode(node) {
@@ -496,7 +341,7 @@ class GraphManager {
size: this.getNodeSize(node.type), size: this.getNodeSize(node.type),
borderColor: this.getNodeBorderColor(node.type), borderColor: this.getNodeBorderColor(node.type),
shape: this.getNodeShape(node.type), shape: this.getNodeShape(node.type),
attributes: node.attributes || [], // Keep as standardized attributes list attributes: node.attributes || {},
description: node.description || '', description: node.description || '',
metadata: node.metadata || {}, metadata: node.metadata || {},
type: node.type, type: node.type,
@@ -508,6 +353,13 @@ class GraphManager {
if (node.confidence) { if (node.confidence) {
processedNode.borderWidth = Math.max(2, Math.floor(node.confidence * 5)); processedNode.borderWidth = Math.max(2, Math.floor(node.confidence * 5));
} }
// Style based on certificate validity
if (node.type === 'domain') {
if (node.attributes && node.attributes.certificates && node.attributes.certificates.has_valid_cert === false) {
processedNode.color = { background: '#888888', border: '#666666' };
}
}
// Handle merged correlation objects (similar to large entities) // Handle merged correlation objects (similar to large entities)
if (node.type === 'correlation_object') { if (node.type === 'correlation_object') {
@@ -556,6 +408,8 @@ class GraphManager {
} }
}; };
return processedEdge; return processedEdge;
} }
@@ -602,6 +456,7 @@ class GraphManager {
return colors[nodeType] || '#ffffff'; return colors[nodeType] || '#ffffff';
} }
/** /**
* Get node border color based on type * Get node border color based on type
* @param {string} nodeType - Node type * @param {string} nodeType - Node type
@@ -991,9 +846,6 @@ class GraphManager {
clear() { clear() {
this.nodes.clear(); this.nodes.clear();
this.edges.clear(); this.edges.clear();
this.history = [];
this.largeEntityMembers.clear(); // Clear large entity tracking
this.clearInitialTargets();
// Show placeholder // Show placeholder
const placeholder = this.container.querySelector('.graph-placeholder'); const placeholder = this.container.querySelector('.graph-placeholder');
@@ -1014,577 +866,59 @@ class GraphManager {
} }
} }
/* * @param {Set} excludedNodeIds - Node IDs to exclude from analysis (for simulation)
* @param {Set} excludedEdgeTypes - Edge types to exclude from traversal
* @param {Set} excludedNodeTypes - Node types to exclude from traversal
* @returns {Object} Analysis results with reachable/unreachable nodes
*/
analyzeGraphReachability(excludedNodeIds = new Set(), excludedEdgeTypes = new Set(), excludedNodeTypes = new Set()) {
console.log("Performing comprehensive reachability analysis...");
const analysis = {
reachableNodes: new Set(),
unreachableNodes: new Set(),
isolatedClusters: [],
affectedNodes: new Set()
};
if (this.nodes.length === 0) return analysis;
// Build adjacency list excluding specified elements
const adjacencyList = {};
this.nodes.getIds().forEach(id => {
if (!excludedNodeIds.has(id)) {
adjacencyList[id] = [];
}
});
this.edges.forEach(edge => {
const edgeType = edge.metadata?.relationship_type || '';
if (!excludedEdgeTypes.has(edgeType) &&
!excludedNodeIds.has(edge.from) &&
!excludedNodeIds.has(edge.to)) {
if (adjacencyList[edge.from]) {
adjacencyList[edge.from].push(edge.to);
}
}
});
// BFS traversal from initial targets
const traversalQueue = [];
// Start from initial targets that aren't excluded
this.initialTargetIds.forEach(rootId => {
if (!excludedNodeIds.has(rootId)) {
const node = this.nodes.get(rootId);
if (node && !excludedNodeTypes.has(node.type)) {
if (!analysis.reachableNodes.has(rootId)) {
traversalQueue.push(rootId);
analysis.reachableNodes.add(rootId);
}
}
}
});
// BFS to find all reachable nodes
let queueIndex = 0;
while (queueIndex < traversalQueue.length) {
const currentNode = traversalQueue[queueIndex++];
for (const neighbor of (adjacencyList[currentNode] || [])) {
if (!analysis.reachableNodes.has(neighbor)) {
const node = this.nodes.get(neighbor);
if (node && !excludedNodeTypes.has(node.type)) {
analysis.reachableNodes.add(neighbor);
traversalQueue.push(neighbor);
}
}
}
}
// Identify unreachable nodes (maintaining forensic integrity)
Object.keys(adjacencyList).forEach(nodeId => {
if (!analysis.reachableNodes.has(nodeId)) {
analysis.unreachableNodes.add(nodeId);
}
});
// Find isolated clusters among unreachable nodes
analysis.isolatedClusters = this.findIsolatedClusters(
Array.from(analysis.unreachableNodes),
adjacencyList
);
console.log(`Reachability analysis complete:`, {
reachable: analysis.reachableNodes.size,
unreachable: analysis.unreachableNodes.size,
clusters: analysis.isolatedClusters.length
});
return analysis;
}
/** /**
* Find isolated clusters within a set of nodes * Get network statistics
* Used for forensic analysis to identify disconnected subgraphs * @returns {Object} Statistics object
*/
findIsolatedClusters(nodeIds, adjacencyList) {
const visited = new Set();
const clusters = [];
for (const nodeId of nodeIds) {
if (!visited.has(nodeId)) {
const cluster = [];
const stack = [nodeId];
while (stack.length > 0) {
const current = stack.pop();
if (!visited.has(current)) {
visited.add(current);
cluster.push(current);
// Add unvisited neighbors within the unreachable set
for (const neighbor of (adjacencyList[current] || [])) {
if (nodeIds.includes(neighbor) && !visited.has(neighbor)) {
stack.push(neighbor);
}
}
}
}
if (cluster.length > 0) {
clusters.push(cluster);
}
}
}
return clusters;
}
/**
* ENHANCED: Get comprehensive graph statistics with forensic information
* Updates the existing getStatistics() method
*/ */
getStatistics() { getStatistics() {
const basicStats = { return {
nodeCount: this.nodes.length, nodeCount: this.nodes.length,
edgeCount: this.edges.length, edgeCount: this.edges.length,
largeEntityMembersHidden: this.largeEntityMembers.size //isStabilized: this.network ? this.network.isStabilized() : false
}; };
// Add forensic statistics
const visibleNodes = this.nodes.get({ filter: node => !node.hidden });
const hiddenNodes = this.nodes.get({ filter: node => node.hidden });
return {
...basicStats,
forensicStatistics: {
visibleNodes: visibleNodes.length,
hiddenNodes: hiddenNodes.length,
initialTargets: this.initialTargetIds.size,
integrityStatus: visibleNodes.length > 0 && this.initialTargetIds.size > 0 ? 'INTACT' : 'COMPROMISED'
}
};
}
addInitialTarget(targetId) {
this.initialTargetIds.add(targetId);
console.log("Initial targets:", this.initialTargetIds);
}
clearInitialTargets() {
this.initialTargetIds.clear();
console.log("Initial targets cleared.");
}
updateFilterControls() {
if (!this.filterPanel) return;
const nodeTypes = new Set(this.nodes.get().map(n => n.type));
const edgeTypes = new Set(this.edges.get().map(e => e.metadata.relationship_type));
// Wrap both columns in a single container with vertical layout
let filterHTML = '<div class="filter-container">';
// Nodes section
filterHTML += '<div class="filter-column"><h4>Nodes</h4><div class="checkbox-group">';
nodeTypes.forEach(type => {
const label = type === 'correlation_object' ? 'latent correlations' : type;
const isChecked = type !== 'correlation_object';
filterHTML += `<label><input type="checkbox" data-filter-type="node" value="${type}" ${isChecked ? 'checked' : ''}> ${label}</label>`;
});
filterHTML += '</div></div>';
// Edges section
filterHTML += '<div class="filter-column"><h4>Edges</h4><div class="checkbox-group">';
edgeTypes.forEach(type => {
filterHTML += `<label><input type="checkbox" data-filter-type="edge" value="${type}" checked> ${type}</label>`;
});
filterHTML += '</div></div>';
filterHTML += '</div>'; // Close filter-container
this.filterPanel.innerHTML = filterHTML;
this.filterPanel.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', () => this.applyAllFilters());
});
} }
/** /**
* ENHANCED: Apply filters using consolidated reachability analysis * Apply filters to the graph
* Replaces the existing applyAllFilters() method * @param {string} nodeType - The type of node to show ('all' for no filter)
* @param {number} minConfidence - The minimum confidence score for edges to be visible
*/ */
applyAllFilters() { applyFilters(nodeType, minConfidence) {
console.log("Applying filters with enhanced reachability analysis..."); console.log(`Applying filters: nodeType=${nodeType}, minConfidence=${minConfidence}`);
if (this.nodes.length === 0) return;
// Get filter criteria from UI const nodeUpdates = [];
const excludedNodeTypes = new Set(); const edgeUpdates = [];
this.filterPanel?.querySelectorAll('input[data-filter-type="node"]:not(:checked)').forEach(cb => {
excludedNodeTypes.add(cb.value);
});
const excludedEdgeTypes = new Set(); const allNodes = this.nodes.get({ returnType: 'Object' });
this.filterPanel?.querySelectorAll('input[data-filter-type="edge"]:not(:checked)').forEach(cb => { const allEdges = this.edges.get();
excludedEdgeTypes.add(cb.value);
});
// Perform comprehensive analysis // Determine which nodes are visible based on the nodeType filter
const analysis = this.analyzeGraphReachability(new Set(), excludedEdgeTypes, excludedNodeTypes); for (const nodeId in allNodes) {
const node = allNodes[nodeId];
// Apply visibility updates const isVisible = (nodeType === 'all' || node.type === nodeType);
const nodeUpdates = this.nodes.map(node => ({ nodeUpdates.push({ id: nodeId, hidden: !isVisible });
id: node.id, }
hidden: !analysis.reachableNodes.has(node.id)
}));
const edgeUpdates = this.edges.map(edge => ({
id: edge.id,
hidden: excludedEdgeTypes.has(edge.metadata?.relationship_type || '') ||
!analysis.reachableNodes.has(edge.from) ||
!analysis.reachableNodes.has(edge.to)
}));
// Update nodes first to determine edge visibility
this.nodes.update(nodeUpdates); this.nodes.update(nodeUpdates);
// Determine which edges are visible based on confidence and connected nodes
for (const edge of allEdges) {
const sourceNode = this.nodes.get(edge.from);
const targetNode = this.nodes.get(edge.to);
const confidence = edge.metadata ? edge.metadata.confidence_score : 0;
const isVisible = confidence >= minConfidence &&
sourceNode && !sourceNode.hidden &&
targetNode && !targetNode.hidden;
edgeUpdates.push({ id: edge.id, hidden: !isVisible });
}
this.edges.update(edgeUpdates); this.edges.update(edgeUpdates);
console.log(`Enhanced filters applied. Visible nodes: ${analysis.reachableNodes.size}`); console.log('Filters applied.');
} }
/**
* ENHANCED: Hide node with forensic integrity using reachability analysis
* Replaces the existing hideNodeAndOrphans() method
*/
hideNodeWithReachabilityAnalysis(nodeId) {
console.log(`Hiding node ${nodeId} with reachability analysis...`);
// Simulate hiding this node and analyze impact
const excludedNodes = new Set([nodeId]);
const analysis = this.analyzeGraphReachability(excludedNodes);
// Nodes that will become unreachable (should be hidden)
const nodesToHide = [nodeId, ...Array.from(analysis.unreachableNodes)];
// Store history for potential revert
const historyData = {
nodeIds: nodesToHide,
operation: 'hide_with_reachability',
timestamp: Date.now()
};
// Apply hiding with forensic documentation
const updates = nodesToHide.map(id => ({
id: id,
hidden: true,
forensicNote: `Hidden due to reachability analysis from ${nodeId}`
}));
this.nodes.update(updates);
this.addToHistory('hide', historyData);
console.log(`Forensic hide operation: ${nodesToHide.length} nodes hidden`, {
originalTarget: nodeId,
cascadeNodes: nodesToHide.length - 1,
isolatedClusters: analysis.isolatedClusters.length
});
return {
hiddenNodes: nodesToHide,
isolatedClusters: analysis.isolatedClusters
};
}
/**
* ENHANCED: Delete node with forensic integrity using reachability analysis
* Replaces the existing deleteNodeAndOrphans() method
*/
async deleteNodeWithReachabilityAnalysis(nodeId) {
console.log(`Deleting node ${nodeId} with reachability analysis...`);
// Simulate deletion and analyze impact
const excludedNodes = new Set([nodeId]);
const analysis = this.analyzeGraphReachability(excludedNodes);
// Nodes that will become unreachable (should be deleted)
const nodesToDelete = [nodeId, ...Array.from(analysis.unreachableNodes)];
// Collect forensic data before deletion
const historyData = {
nodes: nodesToDelete.map(id => this.nodes.get(id)).filter(Boolean),
edges: [],
operation: 'delete_with_reachability',
timestamp: Date.now(),
forensicAnalysis: {
originalTarget: nodeId,
cascadeNodes: nodesToDelete.length - 1,
isolatedClusters: analysis.isolatedClusters.length,
clusterSizes: analysis.isolatedClusters.map(cluster => cluster.length)
}
};
// Collect affected edges
nodesToDelete.forEach(id => {
const connectedEdgeIds = this.network.getConnectedEdges(id);
const edges = this.edges.get(connectedEdgeIds);
historyData.edges.push(...edges);
});
// Remove duplicates from edges
historyData.edges = Array.from(new Map(historyData.edges.map(e => [e.id, e])).values());
// Perform backend deletion with forensic logging
let operationFailed = false;
for (const targetNodeId of nodesToDelete) {
try {
const response = await fetch(`/api/graph/node/${targetNodeId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
forensicContext: {
operation: 'reachability_cascade_delete',
originalTarget: nodeId,
analysisTimestamp: historyData.timestamp
}
})
});
const result = await response.json();
if (!result.success) {
console.error(`Backend deletion failed for node ${targetNodeId}:`, result.error);
operationFailed = true;
break;
}
console.log(`Node ${targetNodeId} deleted from backend with forensic context`);
this.nodes.remove({ id: targetNodeId });
} catch (error) {
console.error(`API error during deletion of node ${targetNodeId}:`, error);
operationFailed = true;
break;
}
}
// Handle operation results
if (!operationFailed) {
this.addToHistory('delete', historyData);
console.log(`Forensic delete operation completed:`, historyData.forensicAnalysis);
return {
success: true,
deletedNodes: nodesToDelete,
forensicAnalysis: historyData.forensicAnalysis
};
} else {
// Revert UI changes if backend operations failed - use update instead of add
console.log("Reverting UI changes due to backend failure");
this.nodes.update(historyData.nodes);
this.edges.update(historyData.edges);
return {
success: false,
error: "Backend deletion failed, UI reverted"
};
}
}
/**
* Show context menu for a node
* @param {string} nodeId - The ID of the node
* @param {Event} event - The contextmenu event
*/
showContextMenu(nodeId, event) {
console.log('Showing context menu for node:', nodeId);
const node = this.nodes.get(nodeId);
// Create menu items
let menuItems = `
<ul>
<li data-action="focus" data-node-id="${nodeId}">
<span class="menu-icon">🎯</span>
<span>Focus on Node</span>
</li>
`;
// Add "Iterate Scan" option only for domain or IP nodes
if (node && (node.type === 'domain' || node.type === 'ip')) {
const disabled = this.isScanning ? 'disabled' : ''; // Check if scanning
const title = this.isScanning ? 'A scan is already in progress' : 'Iterate Scan (Add to Graph)'; // Add a title for disabled state
menuItems += `
<li data-action="iterate" data-node-id="${nodeId}" ${disabled} title="${title}">
<span class="menu-icon"></span>
<span>Iterate Scan (Add to Graph)</span>
</li>
`;
}
menuItems += `
<li data-action="hide" data-node-id="${nodeId}">
<span class="menu-icon">👁️‍🗨️</span>
<span>Hide Node</span>
</li>
<li data-action="delete" data-node-id="${nodeId}">
<span class="menu-icon">🗑️</span>
<span>Delete Node</span>
</li>
<li data-action="details" data-node-id="${nodeId}">
<span class="menu-icon"></span>
<span>Show Details</span>
</li>
</ul>
`;
this.contextMenu.innerHTML = menuItems;
// Position the menu
this.contextMenu.style.left = `${event.clientX}px`;
this.contextMenu.style.top = `${event.clientY}px`;
this.contextMenu.style.display = 'block';
// Ensure menu stays within viewport
const rect = this.contextMenu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
this.contextMenu.style.left = `${event.clientX - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
this.contextMenu.style.top = `${event.clientY - rect.height}px`;
}
// Add event listeners to menu items
this.contextMenu.querySelectorAll('li').forEach(item => {
item.addEventListener('click', (e) => {
if (e.currentTarget.hasAttribute('disabled')) { // Prevent action if disabled
e.stopPropagation();
return;
}
e.stopPropagation();
const action = e.currentTarget.dataset.action;
const nodeId = e.currentTarget.dataset.nodeId;
console.log('Context menu action:', action, 'for node:', nodeId);
this.performContextMenuAction(action, nodeId);
this.hideContextMenu();
});
});
}
/**
* Hide the context menu
*/
hideContextMenu() {
if (this.contextMenu) {
this.contextMenu.style.display = 'none';
}
}
/**
* UPDATED: Enhanced context menu actions using new methods
* Updates the existing performContextMenuAction() method
*/
performContextMenuAction(action, nodeId) {
console.log('Performing enhanced action:', action, 'on node:', nodeId);
switch (action) {
case 'focus':
this.focusOnNode(nodeId);
break;
case 'iterate':
const event = new CustomEvent('iterateScan', {
detail: { nodeId }
});
document.dispatchEvent(event);
break;
case 'hide':
// Use enhanced method with reachability analysis
this.hideNodeWithReachabilityAnalysis(nodeId);
break;
case 'delete':
// Use enhanced method with reachability analysis
this.deleteNodeWithReachabilityAnalysis(nodeId);
break;
case 'details':
const node = this.nodes.get(nodeId);
if (node) {
this.showNodeDetails(node);
}
break;
default:
console.warn('Unknown action:', action);
}
}
/**
* Add an operation to the history stack
* @param {string} type - The type of operation ('hide', 'delete')
* @param {Object} data - The data needed to revert the operation
*/
addToHistory(type, data) {
this.history.push({ type, data });
}
/**
* Revert the last action
*/
async revertLastAction() {
const lastAction = this.history.pop();
if (!lastAction) {
console.log('No actions to revert.');
return;
}
switch (lastAction.type) {
case 'hide':
// Revert hiding nodes by un-hiding them
const updates = lastAction.data.nodeIds.map(id => ({ id: id, hidden: false }));
this.nodes.update(updates);
break;
case 'delete':
try {
const response = await fetch('/api/graph/revert', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(lastAction)
});
const result = await response.json();
if (result.success) {
console.log('Delete action reverted successfully on backend.');
// Re-add all nodes and edges from the history to the local view - use update instead of add
this.nodes.update(lastAction.data.nodes);
this.edges.update(lastAction.data.edges);
} else {
console.error('Failed to revert delete action on backend:', result.error);
// Push the action back onto the history stack if the API call failed
this.history.push(lastAction);
}
} catch (error) {
console.error('Error during revert API call:', error);
this.history.push(lastAction);
}
break;
}
}
/**
* Unhide all hidden nodes
*/
unhideAll() {
const allNodes = this.nodes.get({
filter: (node) => node.hidden === true
});
const updates = allNodes.map(node => ({ id: node.id, hidden: false }));
this.nodes.update(updates);
}
} }
// Export for use in main.js // Export for use in main.js

File diff suppressed because it is too large Load Diff

View File

@@ -32,8 +32,19 @@
<div class="form-container"> <div class="form-container">
<div class="input-group"> <div class="input-group">
<label for="target-input">Target Domain or IP</label> <label for="target-domain">Target Domain</label>
<input type="text" id="target-input" placeholder="example.com or 8.8.8.8" autocomplete="off"> <input type="text" id="target-domain" placeholder="example.com" autocomplete="off">
</div>
<div class="input-group">
<label for="max-depth">Recursion Depth</label>
<select id="max-depth">
<option value="1">Depth 1 - Direct relationships</option>
<option value="2" selected>Depth 2 - Recommended</option>
<option value="3">Depth 3 - Extended analysis</option>
<option value="4">Depth 4 - Deep reconnaissance</option>
<option value="5">Depth 5 - Maximum depth</option>
</select>
</div> </div>
<div class="button-group"> <div class="button-group">
@@ -53,9 +64,9 @@
<span class="btn-icon">[EXPORT]</span> <span class="btn-icon">[EXPORT]</span>
<span>Download Results</span> <span>Download Results</span>
</button> </button>
<button id="configure-settings" class="btn btn-secondary"> <button id="configure-api-keys" class="btn btn-secondary">
<span class="btn-icon">[API]</span> <span class="btn-icon">[API]</span>
<span>Settings</span> <span>Configure API Keys</span>
</button> </button>
</div> </div>
</div> </div>
@@ -93,22 +104,30 @@
<div class="progress-bar"> <div class="progress-bar">
<div id="progress-fill" class="progress-fill"></div> <div id="progress-fill" class="progress-fill"></div>
</div> </div>
<div class="progress-placeholder">
<span class="status-label">
⚠️ <strong>Important:</strong> Scanning large public services (e.g., Google, Cloudflare, AWS) is
<strong>discouraged</strong> due to rate limits (e.g., crt.sh).
<br><br>
Our task scheduler operates on a <strong>priority-based queue</strong>:
Short, targeted tasks like DNS are processed first, while resource-intensive requests (e.g., crt.sh)
are <strong>automatically deprioritized</strong> and may be processed later.
</span>
</div>
</div> </div>
</section> </section>
<section class="visualization-panel"> <section class="visualization-panel">
<div class="panel-header"> <div class="panel-header">
<h2>Infrastructure Map</h2> <h2>Infrastructure Map</h2>
<div class="view-controls">
<div class="filter-group">
<label for="node-type-filter">Node Type:</label>
<select id="node-type-filter">
<option value="all">All</option>
<option value="domain">Domain</option>
<option value="ip">IP</option>
<option value="asn">ASN</option>
<option value="correlation_object">Correlation Object</option>
<option value="large_entity">Large Entity</option>
</select>
</div>
<div class="filter-group">
<label for="confidence-filter">Min Confidence:</label>
<input type="range" id="confidence-filter" min="0" max="1" step="0.1" value="0">
<span id="confidence-value">0</span>
</div>
</div>
</div> </div>
<div id="network-graph" class="graph-container"> <div id="network-graph" class="graph-container">
@@ -186,28 +205,16 @@
</div> </div>
</div> </div>
<div id="settings-modal" class="modal"> <div id="api-key-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>Settings</h3> <h3>Configure API Keys</h3>
<button id="settings-modal-close" class="modal-close">[×]</button> <button id="api-key-modal-close" class="modal-close">[×]</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="modal-description"> <p class="modal-description">
Configure scan settings and API keys. Keys are stored in memory for the current session only. Enter your API keys for enhanced data providers. Keys are stored in memory for the current session only and are never saved to disk.
Only provide API-keys you dont use for anything else. Don´t enter an API-key if you don´t trust me (best practice would that you don´t).
</p> </p>
<br>
<div class="input-group">
<label for="max-depth">Recursion Depth</label>
<select id="max-depth">
<option value="1">Depth 1 - Direct relationships</option>
<option value="2" selected>Depth 2 - Recommended</option>
<option value="3">Depth 3 - Extended analysis</option>
<option value="4">Depth 4 - Deep reconnaissance</option>
<option value="5">Depth 5 - Maximum depth</option>
</select>
</div>
<div id="api-key-inputs"> <div id="api-key-inputs">
</div> </div>
<div class="button-group" style="flex-direction: row; justify-content: flex-end;"> <div class="button-group" style="flex-direction: row; justify-content: flex-end;">
@@ -215,7 +222,7 @@
<span>Reset</span> <span>Reset</span>
</button> </button>
<button id="save-api-keys" class="btn btn-primary"> <button id="save-api-keys" class="btn btn-primary">
<span>Save API-Keys</span> <span>Save Keys</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -48,15 +48,3 @@ def _is_valid_ip(ip: str) -> bool:
except (ValueError, AttributeError): except (ValueError, AttributeError):
return False return False
def is_valid_target(target: str) -> bool:
"""
Checks if the target is a valid domain or IP address.
Args:
target: The target string to validate.
Returns:
True if the target is a valid domain or IP, False otherwise.
"""
return _is_valid_domain(target) or _is_valid_ip(target)