65 Commits

Author SHA1 Message Date
140ef54674 Merge pull request 'data-model' (#2) from data-model into main
Reviewed-on: mstoeck3/dnsrecon#2
2025-09-17 21:56:17 +00:00
overcuriousity
aae459446c update requirements, fix some bugs 2025-09-17 23:55:41 +02:00
overcuriousity
98e1b2280b new node types 2025-09-17 22:42:08 +02:00
overcuriousity
cd14198452 smaller css 2025-09-17 22:39:26 +02:00
overcuriousity
284660ab8c new node types 2025-09-17 22:09:39 +02:00
overcuriousity
ecfb27e02a new scheduling, removed many debug prints 2025-09-17 21:47:03 +02:00
overcuriousity
39b4242200 fix cli last task started 2025-09-17 21:35:54 +02:00
overcuriousity
a56755320c initial targets managed in backend 2025-09-17 21:29:18 +02:00
overcuriousity
b985f1e5f0 potential bugfix for the right click hiding 2025-09-17 21:15:52 +02:00
overcuriousity
8ae4fdbf80 UX improvements 2025-09-17 21:12:11 +02:00
overcuriousity
d0ee415f0d enhancements 2025-09-17 19:42:14 +02:00
overcuriousity
173c3dcf92 some adjustments for clarity 2025-09-17 17:10:11 +02:00
overcuriousity
ec755b17ad remove many unnecessary debug print, improve large entity handling 2025-09-17 13:31:35 +02:00
overcuriousity
469c133f1b fix session handling 2025-09-17 11:18:06 +02:00
overcuriousity
f775c61731 iterating on fixes 2025-09-17 11:08:50 +02:00
overcuriousity
b984189e08 scheduler fixes 2025-09-17 00:31:12 +02:00
overcuriousity
f2db739fa1 attempt to fix some logic 2025-09-17 00:05:48 +02:00
overcuriousity
47ce7ff883 format keys reduction 2025-09-16 23:17:23 +02:00
overcuriousity
229746e1ec improving the display 2025-09-16 22:25:46 +02:00
overcuriousity
733e1da640 new data model refinement 2025-09-16 21:23:02 +02:00
overcuriousity
97aa18f788 implement new data api 2025-09-16 20:21:08 +02:00
overcuriousity
15421dd4a5 update caching logic 2025-09-16 15:36:29 +02:00
overcuriousity
ad4086b156 fix root computation 2025-09-16 15:25:39 +02:00
overcuriousity
0e92ec6e9a readme 2025-09-16 01:00:21 +02:00
overcuriousity
baa57bfac2 update the shodan to use only ip 2025-09-16 00:57:24 +02:00
overcuriousity
f0f80be955 finalize pop-out 2025-09-16 00:32:46 +02:00
overcuriousity
ecc143ddbb extraction feature works 2025-09-16 00:08:27 +02:00
overcuriousity
2c48316477 extract from node feature 2025-09-16 00:01:24 +02:00
overcuriousity
fc098aed28 remove cache 2025-09-15 22:48:49 +02:00
9285226cbc Merge pull request 'new-scheduler' (#1) from new-scheduler into main
Reviewed-on: mstoeck3/dnsrecon#1
2025-09-15 20:45:47 +00:00
overcuriousity
350055fcec successfully implemented scheduler 2025-09-15 22:44:38 +02:00
overcuriousity
4a5ecf7a37 new highest-priority-first scheduler 2025-09-15 22:21:17 +02:00
overcuriousity
71b2855d01 fixes to iteration context menu 2025-09-15 21:37:19 +02:00
overcuriousity
93a258170a context menu option 2025-09-15 21:27:21 +02:00
overcuriousity
e2d4e12057 also allow ip lookups in scan 2025-09-15 21:00:57 +02:00
overcuriousity
c076ee028f main page refinement 2025-09-15 20:44:45 +02:00
overcuriousity
cbfac0922a fix node modals 2025-09-15 20:27:43 +02:00
overcuriousity
881f7b74e5 fix graph delete&revert 2025-09-15 20:20:15 +02:00
overcuriousity
c347581a6c fix graph trueRoot 2025-09-15 20:11:40 +02:00
overcuriousity
30ee21f087 revert graph.js refactor 2025-09-15 18:06:11 +02:00
overcuriousity
2496ca26a5 small fixes 2025-09-15 17:52:09 +02:00
overcuriousity
8aa3c4933e fix large entity 2025-09-15 14:12:02 +02:00
overcuriousity
fc326a66c8 fix large entity 2025-09-15 13:58:30 +02:00
overcuriousity
51902e3155 it 2025-09-15 13:35:58 +02:00
overcuriousity
a261d706c8 update style 2025-09-15 01:10:28 +02:00
overcuriousity
2410e689b8 visual enhancements 2025-09-15 00:25:27 +02:00
overcuriousity
62470673fe integrate checkbox filters 2025-09-14 23:54:27 +02:00
overcuriousity
2658bd148b context menu 2025-09-14 23:42:45 +02:00
overcuriousity
f02381910d Merge branch 'main' of https://git.cc24.dev/mstoeck3/dnsrecon 2025-09-14 23:10:16 +02:00
overcuriousity
674ac59c98 iteration 2025-09-14 23:09:38 +02:00
434d1f4803 dump.rdb gelöscht 2025-09-14 20:55:33 +00:00
overcuriousity
eb9eea127b it 2025-09-14 22:37:23 +02:00
overcuriousity
ae07635ab6 update edge labels 2025-09-14 20:50:09 +02:00
overcuriousity
d7adf9ad8b it 2025-09-14 20:22:09 +02:00
overcuriousity
39ce0e9d11 great progress 2025-09-14 19:12:12 +02:00
overcuriousity
926f9e1096 fixes 2025-09-14 19:06:20 +02:00
overcuriousity
9499e62ccc it 2025-09-14 18:45:02 +02:00
overcuriousity
89ae06482e it 2025-09-14 18:02:15 +02:00
overcuriousity
7fe7ca41ba it 2025-09-14 17:40:18 +02:00
overcuriousity
949fbdbb45 itteration 2025-09-14 17:18:56 +02:00
overcuriousity
689e8c00d4 unify config 2025-09-14 16:17:26 +02:00
overcuriousity
3511f18f9a it 2025-09-14 16:07:58 +02:00
overcuriousity
72f7056bc7 it 2025-09-14 15:31:18 +02:00
overcuriousity
2ae33bc5ba it 2025-09-14 15:00:00 +02:00
overcuriousity
c91913fa13 it 2025-09-14 14:28:04 +02:00
24 changed files with 6133 additions and 3705 deletions

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
# ===============================================
# DNSRecon Environment Variables
# ===============================================
# Copy this file to .env and fill in your values.
# --- API Keys ---
# Add your Shodan API key for the Shodan provider to be enabled.
SHODAN_API_KEY=
# --- Flask & Session Settings ---
# A strong, random secret key is crucial for session security.
FLASK_SECRET_KEY=your-very-secret-and-random-key-here
FLASK_HOST=127.0.0.1
FLASK_PORT=5000
FLASK_DEBUG=True
# How long a user's session in the browser lasts (in hours).
FLASK_PERMANENT_SESSION_LIFETIME_HOURS=2
# How long inactive scanner data is stored in Redis (in minutes).
SESSION_TIMEOUT_MINUTES=60
# --- Application Core Settings ---
# The default number of levels to recurse when scanning.
DEFAULT_RECURSION_DEPTH=2
# Default timeout for provider API requests in seconds.
DEFAULT_TIMEOUT=30
# The number of concurrent provider requests to make.
MAX_CONCURRENT_REQUESTS=1
# The number of results from a provider that triggers the "large entity" grouping.
LARGE_ENTITY_THRESHOLD=100
# The number of times to retry a target if a provider fails.
MAX_RETRIES_PER_TARGET=8
# How long cached provider responses are stored (in hours).
CACHE_TIMEOUT_HOURS=12

1
.gitignore vendored
View File

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

226
README.md
View File

@@ -4,28 +4,32 @@ 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
@@ -44,156 +48,50 @@ source venv/bin/activate
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### 3\. (Optional but Recommended) Set up a Local DNS Caching Resolver The `requirements.txt` file contains the following dependencies:
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). * Flask\>=2.3.3
* 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
sudo apt update cp .env.example .env
sudo apt install unbound -y
``` ```
**b. Configure Unbound:** The following environment variables are available for configuration:
Create a new configuration file for DNSRecon:
```bash | Variable | Description | Default |
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`.
@@ -245,12 +143,18 @@ 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 license agreement found in the `LICENSE` file. This project is licensed under the terms of the **BSD-3-Clause** license.
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.

675
app.py
View File

@@ -1,3 +1,5 @@
# dnsrecon-reduced/app.py
""" """
Flask application entry point for DNSRecon web interface. Flask application entry point for DNSRecon web interface.
Provides REST API endpoints and serves the web interface with user session support. Provides REST API endpoints and serves the web interface with user session support.
@@ -8,56 +10,63 @@ import traceback
from flask import Flask, render_template, request, jsonify, send_file, session from flask import Flask, render_template, request, jsonify, send_file, session
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
import io import io
import os
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
from decimal import Decimal
app = Flask(__name__) app = Flask(__name__)
app.config['SECRET_KEY'] = 'dnsrecon-dev-key-change-in-production' app.config['SECRET_KEY'] = config.flask_secret_key
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=2) # 2 hour session lifetime app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=config.flask_permanent_session_lifetime_hours)
def get_user_scanner(): def get_user_scanner():
""" """
User scanner retrieval with better error handling and debugging. Retrieves the scanner for the current session, or creates a new one if none exists.
""" """
# Get current Flask session info for debugging
current_flask_session_id = session.get('dnsrecon_session_id') current_flask_session_id = session.get('dnsrecon_session_id')
client_ip = request.remote_addr
user_agent = request.headers.get('User-Agent', '')[:100] # Truncate for logging
# Try to get existing session
if current_flask_session_id: if current_flask_session_id:
existing_scanner = session_manager.get_session(current_flask_session_id) existing_scanner = session_manager.get_session(current_flask_session_id)
if existing_scanner: if existing_scanner:
# Ensure session ID is set
existing_scanner.session_id = current_flask_session_id
return current_flask_session_id, existing_scanner return current_flask_session_id, existing_scanner
else:
print(f"Session {current_flask_session_id} not found in session manager")
# Create new session
print("Creating new session...")
new_session_id = session_manager.create_session() new_session_id = session_manager.create_session()
new_scanner = session_manager.get_session(new_session_id) new_scanner = session_manager.get_session(new_session_id)
if not new_scanner: if not new_scanner:
print(f"ERROR: Failed to retrieve newly created session {new_session_id}")
raise Exception("Failed to create new scanner session") raise Exception("Failed to create new scanner session")
# Store in Flask session
session['dnsrecon_session_id'] = new_session_id session['dnsrecon_session_id'] = new_session_id
session.permanent = True session.permanent = True
# Ensure session ID is set on scanner
new_scanner.session_id = new_session_id
print(f"Created new session: {new_session_id}")
print(f"New scanner status: {new_scanner.status}")
print("=== END SESSION DEBUG ===")
return new_session_id, new_scanner return new_session_id, new_scanner
class CustomJSONEncoder(json.JSONEncoder):
"""Custom JSON encoder to handle non-serializable objects."""
def default(self, obj):
if isinstance(obj, datetime):
return obj.isoformat()
elif isinstance(obj, set):
return list(obj)
elif isinstance(obj, Decimal):
return float(obj)
elif hasattr(obj, '__dict__'):
# For custom objects, try to serialize their dict representation
try:
return obj.__dict__
except:
return str(obj)
elif hasattr(obj, 'value') and hasattr(obj, 'name'):
# For enum objects
return obj.value
else:
# For any other non-serializable object, convert to string
return str(obj)
@app.route('/') @app.route('/')
def index(): def index():
"""Serve the main web interface.""" """Serve the main web interface."""
@@ -67,285 +76,276 @@ def index():
@app.route('/api/scan/start', methods=['POST']) @app.route('/api/scan/start', methods=['POST'])
def start_scan(): def start_scan():
""" """
Start a new reconnaissance scan with immediate GUI feedback. Starts a new reconnaissance scan.
""" """
print("=== API: /api/scan/start called ===")
try: try:
print("Getting JSON data from request...")
data = request.get_json() data = request.get_json()
print(f"Request data: {data}") if not data or 'target' not in data:
return jsonify({'success': False, 'error': 'Missing target in request'}), 400
if not data or 'target_domain' not in data: target = data['target'].strip()
print("ERROR: Missing target_domain in request")
return jsonify({
'success': False,
'error': 'Missing target_domain in request'
}), 400
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)
print(f"Parsed - target_domain: '{target_domain}', max_depth: {max_depth}") if not target:
return jsonify({'success': False, 'error': 'Target cannot be empty'}), 400
if not is_valid_target(target):
return jsonify({'success': False, 'error': 'Invalid target format.'}), 400
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
# Validation
if not target_domain:
print("ERROR: Target domain cannot be empty")
return jsonify({
'success': False,
'error': 'Target domain cannot be empty'
}), 400
if not isinstance(max_depth, int) or max_depth < 1 or max_depth > 5:
print(f"ERROR: Invalid max_depth: {max_depth}")
return jsonify({
'success': False,
'error': 'Max depth must be an integer between 1 and 5'
}), 400
print("Validation passed, getting user scanner...")
# Get user-specific scanner
user_session_id, scanner = get_user_scanner() user_session_id, scanner = get_user_scanner()
# Ensure session ID is properly set if not scanner:
if not scanner.session_id: return jsonify({'success': False, 'error': 'Failed to get scanner instance.'}), 500
scanner.session_id = user_session_id
print(f"Using session: {user_session_id}") success = scanner.start_scan(target, max_depth, clear_graph=clear_graph, force_rescan_target=force_rescan_target)
print(f"Scanner object ID: {id(scanner)}")
# Start scan
print(f"Calling start_scan on scanner {id(scanner)}...")
success = scanner.start_scan(target_domain, max_depth, clear_graph=clear_graph)
# Immediately update session state regardless of success
session_manager.update_session_scanner(user_session_id, scanner)
if success: if success:
scan_session_id = scanner.logger.session_id
print(f"Scan started successfully with scan session ID: {scan_session_id}")
return jsonify({ return jsonify({
'success': True, 'success': True,
'message': 'Scan started successfully', 'message': 'Scan started successfully',
'scan_id': scan_session_id, 'scan_id': scanner.logger.session_id,
'user_session_id': user_session_id, 'user_session_id': user_session_id
'scanner_status': scanner.status,
'debug_info': {
'scanner_object_id': id(scanner),
'scanner_status': scanner.status
}
}) })
else: else:
print("ERROR: Scanner returned False")
# Provide more detailed error information
error_details = {
'scanner_status': scanner.status,
'scanner_object_id': id(scanner),
'session_id': user_session_id,
'providers_count': len(scanner.providers) if hasattr(scanner, 'providers') else 0
}
return jsonify({ return jsonify({
'success': False, 'success': False,
'error': f'Failed to start scan (scanner status: {scanner.status})', 'error': f'Failed to start scan (scanner status: {scanner.status})',
'debug_info': error_details
}), 409 }), 409
except Exception as e: except Exception as e:
print(f"ERROR: Exception in start_scan endpoint: {e}")
traceback.print_exc() traceback.print_exc()
return jsonify({ return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
'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."""
print("=== API: /api/scan/stop called ===")
try: try:
# Get user-specific scanner
user_session_id, scanner = get_user_scanner() user_session_id, scanner = get_user_scanner()
print(f"Stopping scan for session: {user_session_id}")
if not scanner: if not scanner:
return jsonify({ return jsonify({'success': False, 'error': 'No scanner found for session'}), 404
'success': False,
'error': 'No scanner found for session'
}), 404
# Ensure session ID is set
if not scanner.session_id: if not scanner.session_id:
scanner.session_id = user_session_id scanner.session_id = user_session_id
# Use the stop mechanism scanner.stop_scan()
success = scanner.stop_scan()
# Also set the Redis stop signal directly for extra reliability
session_manager.set_stop_signal(user_session_id) session_manager.set_stop_signal(user_session_id)
# Force immediate status update
session_manager.update_scanner_status(user_session_id, 'stopped') session_manager.update_scanner_status(user_session_id, 'stopped')
# Update the full scanner state
session_manager.update_session_scanner(user_session_id, scanner) session_manager.update_session_scanner(user_session_id, scanner)
print(f"Stop scan completed. Success: {success}, Scanner status: {scanner.status}")
return jsonify({ return jsonify({
'success': True, 'success': True,
'message': 'Scan stop requested - termination initiated', 'message': 'Scan stop requested',
'user_session_id': user_session_id, 'user_session_id': user_session_id
'scanner_status': scanner.status,
'stop_method': 'cross_process'
}) })
except Exception as e: except Exception as e:
print(f"ERROR: Exception in stop_scan endpoint: {e}")
traceback.print_exc() traceback.print_exc()
return jsonify({ return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
'success': False,
'error': f'Internal server error: {str(e)}'
}), 500
@app.route('/api/scan/status', methods=['GET']) @app.route('/api/scan/status', methods=['GET'])
def get_scan_status(): def get_scan_status():
"""Get current scan status with error handling.""" """Get current scan status."""
try: try:
# Get user-specific scanner
user_session_id, scanner = get_user_scanner() user_session_id, scanner = get_user_scanner()
if not scanner: if not scanner:
# Return default idle status if no scanner
return jsonify({ return jsonify({
'success': True, 'success': True,
'status': { 'status': {
'status': 'idle', 'status': 'idle', 'target_domain': None, 'current_depth': 0,
'target_domain': None, 'max_depth': 0, 'progress_percentage': 0.0,
'current_depth': 0,
'max_depth': 0,
'current_indicator': '',
'total_indicators_found': 0,
'indicators_processed': 0,
'progress_percentage': 0.0,
'enabled_providers': [],
'graph_statistics': {},
'user_session_id': user_session_id 'user_session_id': user_session_id
} }
}) })
# Ensure session ID is set
if not scanner.session_id: if not scanner.session_id:
scanner.session_id = user_session_id scanner.session_id = user_session_id
status = scanner.get_scan_status() status = scanner.get_scan_status()
status['user_session_id'] = user_session_id status['user_session_id'] = user_session_id
# Additional debug info return jsonify({'success': True, 'status': status})
status['debug_info'] = {
'scanner_object_id': id(scanner),
'session_id_set': bool(scanner.session_id),
'has_scan_thread': bool(scanner.scan_thread and scanner.scan_thread.is_alive())
}
return jsonify({
'success': True,
'status': status
})
except Exception as e: except Exception as e:
print(f"ERROR: Exception in get_scan_status endpoint: {e}")
traceback.print_exc() traceback.print_exc()
return jsonify({ return jsonify({
'success': False, 'success': False, 'error': f'Internal server error: {str(e)}',
'error': f'Internal server error: {str(e)}', 'fallback_status': {'status': 'error', 'progress_percentage': 0.0}
'fallback_status': {
'status': 'error',
'target_domain': None,
'current_depth': 0,
'max_depth': 0,
'progress_percentage': 0.0
}
}), 500 }), 500
@app.route('/api/graph', methods=['GET']) @app.route('/api/graph', methods=['GET'])
def get_graph_data(): def get_graph_data():
"""Get current graph data with error handling.""" """Get current graph data."""
try: try:
# Get user-specific scanner
user_session_id, scanner = get_user_scanner() user_session_id, scanner = get_user_scanner()
if not scanner: empty_graph = {
# Return empty graph if no scanner 'nodes': [], 'edges': [],
return jsonify({ 'statistics': {'node_count': 0, 'edge_count': 0}
'success': True, }
'graph': {
'nodes': [],
'edges': [],
'statistics': {
'node_count': 0,
'edge_count': 0,
'creation_time': datetime.now(timezone.utc).isoformat(),
'last_modified': datetime.now(timezone.utc).isoformat()
}
},
'user_session_id': user_session_id
})
graph_data = scanner.get_graph_data() if not scanner:
return jsonify({ return jsonify({'success': True, 'graph': empty_graph, 'user_session_id': user_session_id})
'success': True,
'graph': graph_data, graph_data = scanner.get_graph_data() or empty_graph
'user_session_id': user_session_id
}) return jsonify({'success': True, 'graph': graph_data, 'user_session_id': user_session_id})
except Exception as e: except Exception as e:
print(f"ERROR: Exception in get_graph_data endpoint: {e}")
traceback.print_exc() traceback.print_exc()
return jsonify({ return jsonify({
'success': False, 'success': False, 'error': f'Internal server error: {str(e)}',
'error': f'Internal server error: {str(e)}', 'fallback_graph': {'nodes': [], 'edges': [], 'statistics': {}}
'fallback_graph': {
'nodes': [],
'edges': [],
'statistics': {'node_count': 0, 'edge_count': 0}
}
}), 500 }), 500
@app.route('/api/graph/large-entity/extract', methods=['POST'])
def extract_from_large_entity():
"""Extract a node from a large entity."""
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:
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."""
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:
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.'}), 404
except Exception as 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':
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')
)
edges_to_add = action_data.get('edges', [])
for edge in edges_to_add:
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', {})
)
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:
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'])
def export_results(): def export_results():
"""Export complete scan results as downloadable JSON for the user session.""" """Export scan results as a JSON file with improved error handling."""
try: try:
# Get user-specific scanner
user_session_id, scanner = get_user_scanner() user_session_id, scanner = get_user_scanner()
# Get complete results if not scanner:
results = scanner.export_results() return jsonify({'success': False, 'error': 'No active scanner session found'}), 404
# Add session information to export # Get export data with error handling
try:
results = scanner.export_results()
except Exception as e:
return jsonify({'success': False, 'error': f'Failed to gather export data: {str(e)}'}), 500
# Add export metadata
results['export_metadata'] = { results['export_metadata'] = {
'user_session_id': user_session_id, 'user_session_id': user_session_id,
'export_timestamp': datetime.now(timezone.utc).isoformat(), 'export_timestamp': datetime.now(timezone.utc).isoformat(),
'export_type': 'user_session_results' 'export_version': '1.0.0',
'forensic_integrity': 'maintained'
} }
# Create filename with timestamp # Generate filename with forensic naming convention
timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S') timestamp = datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')
target = scanner.current_target or 'unknown' target = scanner.current_target or 'unknown'
filename = f"dnsrecon_{target}_{timestamp}_{user_session_id[:8]}.json" # Sanitize target for filename
safe_target = "".join(c for c in target if c.isalnum() or c in ('-', '_', '.')).rstrip()
filename = f"dnsrecon_{safe_target}_{timestamp}.json"
# Create in-memory file # Serialize with custom encoder and error handling
json_data = json.dumps(results, indent=2, ensure_ascii=False) try:
json_data = json.dumps(results, indent=2, cls=CustomJSONEncoder, ensure_ascii=False)
except Exception as e:
# If custom encoder fails, try a more aggressive approach
try:
# Convert problematic objects to strings recursively
cleaned_results = _clean_for_json(results)
json_data = json.dumps(cleaned_results, indent=2, ensure_ascii=False)
except Exception as e2:
return jsonify({
'success': False,
'error': f'JSON serialization failed: {str(e2)}'
}), 500
# Create file object
file_obj = io.BytesIO(json_data.encode('utf-8')) file_obj = io.BytesIO(json_data.encode('utf-8'))
return send_file( return send_file(
@@ -356,64 +356,70 @@ def export_results():
) )
except Exception as e: except Exception as e:
print(f"ERROR: Exception in export_results endpoint: {e}")
traceback.print_exc() traceback.print_exc()
return jsonify({ return jsonify({
'success': False, 'success': False,
'error': f'Export failed: {str(e)}' 'error': f'Export failed: {str(e)}',
'error_type': type(e).__name__
}), 500 }), 500
def _clean_for_json(obj, max_depth=10, current_depth=0):
"""
Recursively clean an object to make it JSON serializable.
Handles circular references and problematic object types.
"""
if current_depth > max_depth:
return f"<max_depth_exceeded_{type(obj).__name__}>"
@app.route('/api/providers', methods=['GET']) if obj is None or isinstance(obj, (bool, int, float, str)):
def get_providers(): return obj
"""Get information about available providers for the user session.""" elif isinstance(obj, datetime):
print("=== API: /api/providers called ===") return obj.isoformat()
elif isinstance(obj, (set, frozenset)):
try: return list(obj)
# Get user-specific scanner elif isinstance(obj, dict):
user_session_id, scanner = get_user_scanner() cleaned = {}
for key, value in obj.items():
provider_info = scanner.get_provider_info() try:
# Ensure key is string
return jsonify({ clean_key = str(key) if not isinstance(key, str) else key
'success': True, cleaned[clean_key] = _clean_for_json(value, max_depth, current_depth + 1)
'providers': provider_info, except Exception:
'user_session_id': user_session_id cleaned[str(key)] = f"<serialization_error_{type(value).__name__}>"
}) return cleaned
elif isinstance(obj, (list, tuple)):
except Exception as e: cleaned = []
print(f"ERROR: Exception in get_providers endpoint: {e}") for item in obj:
traceback.print_exc() try:
return jsonify({ cleaned.append(_clean_for_json(item, max_depth, current_depth + 1))
'success': False, except Exception:
'error': f'Internal server error: {str(e)}' cleaned.append(f"<serialization_error_{type(item).__name__}>")
}), 500 return cleaned
elif hasattr(obj, '__dict__'):
try:
return _clean_for_json(obj.__dict__, max_depth, current_depth + 1)
except Exception:
return str(obj)
elif hasattr(obj, 'value'):
# For enum-like objects
return obj.value
else:
return str(obj)
@app.route('/api/config/api-keys', methods=['POST']) @app.route('/api/config/api-keys', methods=['POST'])
def set_api_keys(): def set_api_keys():
""" """Set API keys for the current session."""
Set API keys for providers for the user session only.
"""
try: try:
data = request.get_json() data = request.get_json()
if data is None: if data is None:
return jsonify({ return jsonify({'success': False, 'error': 'No API keys provided'}), 400
'success': False,
'error': 'No API keys provided'
}), 400
# Get user-specific scanner and config
user_session_id, scanner = get_user_scanner() user_session_id, scanner = get_user_scanner()
session_config = scanner.config session_config = scanner.config
updated_providers = [] updated_providers = []
# Iterate over the API keys provided in the request data
for provider_name, api_key in data.items(): for provider_name, api_key in data.items():
# This allows us to both set and clear keys. The config
# handles enabling/disabling based on if the key is empty.
api_key_value = str(api_key or '').strip() api_key_value = str(api_key or '').strip()
success = session_config.set_api_key(provider_name.lower(), api_key_value) success = session_config.set_api_key(provider_name.lower(), api_key_value)
@@ -421,167 +427,136 @@ def set_api_keys():
updated_providers.append(provider_name) updated_providers.append(provider_name)
if updated_providers: if updated_providers:
# Reinitialize scanner providers to apply the new keys
scanner._initialize_providers() scanner._initialize_providers()
# Persist the updated scanner object back to the user's session
session_manager.update_session_scanner(user_session_id, scanner) session_manager.update_session_scanner(user_session_id, scanner)
return jsonify({ return jsonify({
'success': True, 'success': True,
'message': f'API keys updated for session {user_session_id}: {", ".join(updated_providers)}', 'message': f'API keys updated for: {", ".join(updated_providers)}',
'updated_providers': updated_providers,
'user_session_id': user_session_id 'user_session_id': user_session_id
}) })
else: else:
return jsonify({ return jsonify({'success': False, 'error': 'No valid API keys were provided.'}), 400
'success': False,
'error': 'No valid API keys were provided or provider names were incorrect.'
}), 400
except Exception as e: except Exception as e:
print(f"ERROR: Exception in set_api_keys endpoint: {e}")
traceback.print_exc() traceback.print_exc()
return jsonify({ return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
'success': False,
'error': f'Internal server error: {str(e)}'
}), 500
@app.route('/api/providers', methods=['GET'])
@app.route('/api/session/info', methods=['GET']) def get_providers():
def get_session_info(): """Get enhanced information about available providers including API key sources."""
"""Get information about the current user session."""
try: try:
user_session_id, scanner = get_user_scanner() user_session_id, scanner = get_user_scanner()
session_info = session_manager.get_session_info(user_session_id) base_provider_info = scanner.get_provider_info()
# Enhance provider info with API key source information
enhanced_provider_info = {}
for provider_name, info in base_provider_info.items():
enhanced_info = dict(info) # Copy base info
if info['requires_api_key']:
# Determine API key source and configuration status
api_key = scanner.config.get_api_key(provider_name)
backend_api_key = os.getenv(f'{provider_name.upper()}_API_KEY')
if backend_api_key:
# API key configured via backend/environment
enhanced_info.update({
'api_key_configured': True,
'api_key_source': 'backend',
'api_key_help': f'API key configured via environment variable {provider_name.upper()}_API_KEY'
})
elif api_key:
# API key configured via web interface
enhanced_info.update({
'api_key_configured': True,
'api_key_source': 'frontend',
'api_key_help': f'API key set via web interface (session-only)'
})
else:
# No API key configured
enhanced_info.update({
'api_key_configured': False,
'api_key_source': None,
'api_key_help': f'Requires API key to enable {info["display_name"]} integration'
})
else:
# Provider doesn't require API key
enhanced_info.update({
'api_key_configured': True, # Always "configured" for non-API providers
'api_key_source': None,
'api_key_help': None
})
enhanced_provider_info[provider_name] = enhanced_info
return jsonify({ return jsonify({
'success': True, 'success': True,
'session_info': session_info 'providers': enhanced_provider_info,
'user_session_id': user_session_id
}) })
except Exception as e: except Exception as e:
print(f"ERROR: Exception in get_session_info endpoint: {e}")
traceback.print_exc() traceback.print_exc()
return jsonify({ return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
'success': False,
'error': f'Internal server error: {str(e)}'
}), 500
@app.route('/api/session/terminate', methods=['POST']) @app.route('/api/config/providers', methods=['POST'])
def terminate_session(): def configure_providers():
"""Terminate the current user session.""" """Configure provider settings (enable/disable)."""
try: try:
user_session_id = session.get('dnsrecon_session_id') data = request.get_json()
if data is None:
return jsonify({'success': False, 'error': 'No provider settings provided'}), 400
if user_session_id: user_session_id, scanner = get_user_scanner()
success = session_manager.terminate_session(user_session_id) session_config = scanner.config
# Clear Flask session
session.pop('dnsrecon_session_id', None) updated_providers = []
for provider_name, settings in data.items():
provider_name_clean = provider_name.lower().strip()
if 'enabled' in settings:
# Update the enabled state in session config
session_config.enabled_providers[provider_name_clean] = settings['enabled']
updated_providers.append(provider_name_clean)
if updated_providers:
# Reinitialize providers with new settings
scanner._initialize_providers()
session_manager.update_session_scanner(user_session_id, scanner)
return jsonify({ return jsonify({
'success': success, 'success': True,
'message': 'Session terminated' if success else 'Session not found' 'message': f'Provider settings updated for: {", ".join(updated_providers)}',
'user_session_id': user_session_id
}) })
else: else:
return jsonify({ return jsonify({'success': False, 'error': 'No valid provider settings were provided.'}), 400
'success': False,
'error': 'No active session to terminate'
}), 400
except Exception as e: except Exception as e:
print(f"ERROR: Exception in terminate_session endpoint: {e}")
traceback.print_exc() traceback.print_exc()
return jsonify({ return jsonify({'success': False, 'error': f'Internal server error: {str(e)}'}), 500
'success': False,
'error': f'Internal server error: {str(e)}'
}), 500
@app.route('/api/admin/sessions', methods=['GET'])
def list_sessions():
"""Admin endpoint to list all active sessions."""
try:
sessions = session_manager.list_active_sessions()
stats = session_manager.get_statistics()
return jsonify({
'success': True,
'sessions': sessions,
'statistics': stats
})
except Exception as e:
print(f"ERROR: Exception in list_sessions endpoint: {e}")
traceback.print_exc()
return jsonify({
'success': False,
'error': f'Internal server error: {str(e)}'
}), 500
@app.route('/api/health', methods=['GET'])
def health_check():
"""Health check endpoint."""
try:
# Get session stats
session_stats = session_manager.get_statistics()
return jsonify({
'success': True,
'status': 'healthy',
'timestamp': datetime.now(timezone.utc).isoformat(),
'version': '1.0.0-phase2',
'phase': 2,
'features': {
'multi_provider': True,
'concurrent_processing': True,
'real_time_updates': True,
'api_key_management': True,
'visualization': True,
'retry_logic': True,
'user_sessions': True,
'session_isolation': True
},
'session_statistics': session_stats
})
except Exception as e:
print(f"ERROR: Exception in health_check endpoint: {e}")
return jsonify({
'success': False,
'error': f'Health check failed: {str(e)}'
}), 500
@app.errorhandler(404) @app.errorhandler(404)
def not_found(error): def not_found(error):
"""Handle 404 errors.""" """Handle 404 errors."""
return jsonify({ return jsonify({'success': False, 'error': 'Endpoint not found'}), 404
'success': False,
'error': 'Endpoint not found'
}), 404
@app.errorhandler(500) @app.errorhandler(500)
def internal_error(error): def internal_error(error):
"""Handle 500 errors.""" """Handle 500 errors."""
print(f"ERROR: 500 Internal Server Error: {error}")
traceback.print_exc() traceback.print_exc()
return jsonify({ return jsonify({'success': False, 'error': 'Internal server error'}), 500
'success': False,
'error': 'Internal server error'
}), 500
if __name__ == '__main__': if __name__ == '__main__':
print("Starting DNSRecon Flask application with user session support...")
# Load configuration from environment
config.load_from_env() config.load_from_env()
# Start Flask application
print(f"Starting server on {config.flask_host}:{config.flask_port}")
app.run( app.run(
host=config.flask_host, host=config.flask_host,
port=config.flask_port, port=config.flask_port,

191
config.py
View File

@@ -1,3 +1,5 @@
# 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.
@@ -5,110 +7,149 @@ Handles API key storage, rate limiting, and default settings.
import os import os
from typing import Dict, Optional from typing import Dict, Optional
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
class Config: class Config:
"""Configuration manager for DNSRecon application.""" """Configuration manager for DNSRecon application."""
def __init__(self): def __init__(self):
"""Initialize configuration with default values.""" """Initialize configuration with default values."""
self.api_keys: Dict[str, Optional[str]] = { self.api_keys: Dict[str, Optional[str]] = {}
'shodan': None
}
# Default settings # --- General Settings ---
self.default_recursion_depth = 2 self.default_recursion_depth = 2
self.default_timeout = 10 self.default_timeout = 60
self.max_concurrent_requests = 5 self.max_concurrent_requests = 1
self.large_entity_threshold = 100 self.large_entity_threshold = 100
self.max_retries_per_target = 8
# Rate limiting settings (requests per minute) # --- Provider Caching Settings ---
self.cache_timeout_hours = 6 # Provider-specific cache timeout
# --- Rate Limiting (requests per minute) ---
self.rate_limits = { self.rate_limits = {
'crtsh': 60, # Free service, be respectful 'crtsh': 5,
'shodan': 60, # API dependent 'shodan': 60,
'dns': 100 # Local DNS queries 'dns': 100
} }
# Provider settings # --- Provider Settings ---
self.enabled_providers = { self.enabled_providers = {
'crtsh': True, # Always enabled (free) 'crtsh': True,
'dns': True, # Always enabled (free) 'dns': True,
'shodan': False # Requires API key 'shodan': False
} }
# Logging configuration # --- Logging ---
self.log_level = 'INFO' self.log_level = 'INFO'
self.log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' self.log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
# Flask configuration # --- Flask & Session Settings ---
self.flask_host = '127.0.0.1' self.flask_host = '127.0.0.1'
self.flask_port = 5000 self.flask_port = 5000
self.flask_debug = True self.flask_debug = True
self.flask_secret_key = 'default-secret-key-change-me'
self.flask_permanent_session_lifetime_hours = 2
self.session_timeout_minutes = 60
def set_api_key(self, provider: str, api_key: str) -> bool: # Load environment variables to override defaults
""" self.load_from_env()
Set API key for a provider.
Args:
provider: Provider name (shodan, etc)
api_key: API key string
Returns:
bool: True if key was set successfully
"""
if provider in self.api_keys:
self.api_keys[provider] = api_key
self.enabled_providers[provider] = True if api_key else False
return True
return False
def get_api_key(self, provider: str) -> Optional[str]:
"""
Get API key for a provider.
Args:
provider: Provider name
Returns:
API key or None if not set
"""
return self.api_keys.get(provider)
def is_provider_enabled(self, provider: str) -> bool:
"""
Check if a provider is enabled.
Args:
provider: Provider name
Returns:
bool: True if provider is enabled
"""
return self.enabled_providers.get(provider, False)
def get_rate_limit(self, provider: str) -> int:
"""
Get rate limit for a provider.
Args:
provider: Provider name
Returns:
Rate limit in requests per minute
"""
return self.rate_limits.get(provider, 60)
def load_from_env(self): def load_from_env(self):
"""Load configuration from environment variables.""" """Load configuration from environment variables."""
if os.getenv('SHODAN_API_KEY'): self.set_api_key('shodan', os.getenv('SHODAN_API_KEY'))
self.set_api_key('shodan', os.getenv('SHODAN_API_KEY'))
# Override default settings from environment # Override settings from environment
self.default_recursion_depth = int(os.getenv('DEFAULT_RECURSION_DEPTH', '2')) self.default_recursion_depth = int(os.getenv('DEFAULT_RECURSION_DEPTH', self.default_recursion_depth))
self.flask_debug = os.getenv('FLASK_DEBUG', 'True').lower() == 'true' self.default_timeout = int(os.getenv('DEFAULT_TIMEOUT', self.default_timeout))
self.default_timeout = 30 self.max_concurrent_requests = int(os.getenv('MAX_CONCURRENT_REQUESTS', self.max_concurrent_requests))
self.max_concurrent_requests = 5 self.large_entity_threshold = int(os.getenv('LARGE_ENTITY_THRESHOLD', self.large_entity_threshold))
self.max_retries_per_target = int(os.getenv('MAX_RETRIES_PER_TARGET', self.max_retries_per_target))
self.cache_timeout_hours = int(os.getenv('CACHE_TIMEOUT_HOURS', self.cache_timeout_hours))
# Override Flask and session settings
self.flask_host = os.getenv('FLASK_HOST', self.flask_host)
self.flask_port = int(os.getenv('FLASK_PORT', self.flask_port))
self.flask_debug = os.getenv('FLASK_DEBUG', str(self.flask_debug)).lower() == 'true'
self.flask_secret_key = os.getenv('FLASK_SECRET_KEY', self.flask_secret_key)
self.flask_permanent_session_lifetime_hours = int(os.getenv('FLASK_PERMANENT_SESSION_LIFETIME_HOURS', self.flask_permanent_session_lifetime_hours))
self.session_timeout_minutes = int(os.getenv('SESSION_TIMEOUT_MINUTES', self.session_timeout_minutes))
def set_api_key(self, provider: str, api_key: Optional[str]) -> bool:
"""Set API key for a provider."""
self.api_keys[provider] = api_key
if api_key:
self.enabled_providers[provider] = True
return True
def set_provider_enabled(self, provider: str, enabled: bool) -> bool:
"""
Set provider enabled status for the session.
Args:
provider: Provider name
enabled: Whether the provider should be enabled
Returns:
True if the setting was applied successfully
"""
provider_key = provider.lower()
self.enabled_providers[provider_key] = enabled
return True
def get_provider_enabled(self, provider: str) -> bool:
"""
Get provider enabled status.
Args:
provider: Provider name
Returns:
True if the provider is enabled
"""
provider_key = provider.lower()
return self.enabled_providers.get(provider_key, True) # Default to enabled
def bulk_set_provider_settings(self, provider_settings: dict) -> dict:
"""
Set multiple provider settings at once.
Args:
provider_settings: Dict of provider_name -> {'enabled': bool, ...}
Returns:
Dict with results for each provider
"""
results = {}
for provider_name, settings in provider_settings.items():
provider_key = provider_name.lower()
try:
if 'enabled' in settings:
self.enabled_providers[provider_key] = settings['enabled']
results[provider_key] = {'success': True, 'enabled': settings['enabled']}
else:
results[provider_key] = {'success': False, 'error': 'No enabled setting provided'}
except Exception as e:
results[provider_key] = {'success': False, 'error': str(e)}
return results
def get_api_key(self, provider: str) -> Optional[str]:
"""Get API key for a provider."""
return self.api_keys.get(provider)
def is_provider_enabled(self, provider: str) -> bool:
"""Check if a provider is enabled."""
return self.enabled_providers.get(provider, False)
def get_rate_limit(self, provider: str) -> int:
"""Get rate limit for a provider."""
return self.rate_limits.get(provider, 60)
# Global configuration instance # Global configuration instance
config = Config() config = Config()

View File

@@ -1,6 +1,10 @@
# dnsrecon-reduced/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 correlation exclusion keys to match actual attribute names.
""" """
import re import re
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -14,7 +18,8 @@ class NodeType(Enum):
"""Enumeration of supported node types.""" """Enumeration of supported node types."""
DOMAIN = "domain" DOMAIN = "domain"
IP = "ip" IP = "ip"
ASN = "asn" ISP = "isp"
CA = "ca"
LARGE_ENTITY = "large_entity" LARGE_ENTITY = "large_entity"
CORRELATION_OBJECT = "correlation_object" CORRELATION_OBJECT = "correlation_object"
@@ -26,6 +31,7 @@ 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):
@@ -37,6 +43,31 @@ class GraphManager:
# 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}')
# FIXED: Exclude cert_issuer_name since we already create proper CA relationships
self.EXCLUDED_KEYS = [
# Certificate metadata that creates noise or has dedicated node types
'cert_source', # Always 'crtsh' for crtsh provider
'cert_common_name',
'cert_validity_period_days', # Numerical, not useful for correlation
'cert_issuer_name', # FIXED: Has dedicated CA nodes, don't correlate
#'cert_certificate_id', # Unique per certificate
#'cert_serial_number', # Unique per certificate
'cert_entry_timestamp', # Timestamp, filtered by date regex anyway
'cert_not_before', # Date, filtered by date regex anyway
'cert_not_after', # Date, filtered by date regex anyway
# DNS metadata that creates noise
'dns_ttl', # TTL values are not meaningful for correlation
# Shodan metadata that might create noise
'timestamp', # Generic timestamp fields
'last_update', # Generic timestamp fields
#'org', # Too generic, causes false correlations
#'isp', # Too generic, causes false correlations
# Generic noisy attributes
'updated_timestamp', # Any timestamp field
'discovery_timestamp', # Any timestamp field
'query_timestamp', # Any timestamp field
]
def __getstate__(self): def __getstate__(self):
"""Prepare GraphManager for pickling, excluding compiled regex.""" """Prepare GraphManager for pickling, excluding compiled regex."""
state = self.__dict__.copy() state = self.__dict__.copy()
@@ -50,178 +81,138 @@ 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 _update_correlation_index(self, node_id: str, data: Any, path: List[str] = None): def process_correlations_for_node(self, node_id: str):
"""Recursively traverse metadata and add hashable values to the index.""" """
if path is None: UPDATED: Process correlations for a given node with enhanced tracking.
path = [] Now properly tracks which attribute/provider created each correlation.
"""
if isinstance(data, dict): if not self.graph.has_node(node_id):
for key, value in data.items():
self._update_correlation_index(node_id, value, path + [key])
elif isinstance(data, list):
for i, item in enumerate(data):
self._update_correlation_index(node_id, item, path + [f"[{i}]"])
else:
self._add_to_correlation_index(node_id, data, ".".join(path))
def _add_to_correlation_index(self, node_id: str, value: Any, path_str: 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
# Ignore certain paths that contain noisy, non-unique identifiers node_attributes = self.graph.nodes[node_id].get('attributes', [])
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 # Process each attribute for potential correlations
if isinstance(value, str): for attr in node_attributes:
# FIXED: Prevent correlation on date/time strings. attr_name = attr.get('name')
if self.date_pattern.match(value): attr_value = attr.get('value')
return attr_provider = attr.get('provider', 'unknown')
if len(value) < 4 or value.lower() in ['true', 'false', 'unknown', 'none', 'crt.sh']:
return
elif isinstance(value, int) and abs(value) < 9999:
return # Ignore small integers
elif isinstance(value, bool):
return # Ignore boolean values
# Add the valuable correlation data to the index # IMPROVED: More comprehensive exclusion logic
if value not in self.correlation_index: should_exclude = (
self.correlation_index[value] = {} # Check against excluded keys (exact match or substring)
if node_id not in self.correlation_index[value]: any(excluded_key in attr_name or attr_name == excluded_key for excluded_key in self.EXCLUDED_KEYS) or
self.correlation_index[value][node_id] = [] # Invalid value types
if path_str not in self.correlation_index[value][node_id]: not isinstance(attr_value, (str, int, float, bool)) or
self.correlation_index[value][node_id].append(path_str) attr_value is None or
# Boolean values are not useful for correlation
isinstance(attr_value, bool) or
# String values that are too short or are dates
(isinstance(attr_value, str) and (
len(attr_value) < 4 or
self.date_pattern.match(attr_value) or
# Exclude common generic values that create noise
attr_value.lower() in ['unknown', 'none', 'null', 'n/a', 'true', 'false', '0', '1']
)) or
# Numerical values that are likely to be unique identifiers
(isinstance(attr_value, (int, float)) and (
attr_value == 0 or # Zero values are not meaningful
attr_value == 1 or # One values are too common
abs(attr_value) > 1000000 # Very large numbers are likely IDs
))
)
def _check_for_correlations(self, new_node_id: str, data: Any, path: List[str] = None) -> List[Dict]: if should_exclude:
"""Recursively traverse metadata to find correlations with existing data.""" continue
if path is None:
path = []
all_correlations = [] # Initialize correlation tracking for this value
if isinstance(data, dict): if attr_value not in self.correlation_index:
for key, value in data.items(): self.correlation_index[attr_value] = {
if key == 'source': # Avoid correlating on the provider name 'nodes': set(),
continue 'sources': [] # Track which provider/attribute combinations contributed
all_correlations.extend(self._check_for_correlations(new_node_id, value, path + [key])) }
elif isinstance(data, list):
for i, item in enumerate(data):
all_correlations.extend(self._check_for_correlations(new_node_id, item, path + [f"[{i}]"]))
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: # Add this node and source information
return all_correlations # Correlation must involve at least two distinct nodes self.correlation_index[attr_value]['nodes'].add(node_id)
new_source = {'node_id': new_node_id, 'path': ".".join(path)} # Track the source of this correlation value
all_sources = [new_source] source_info = {
for node_id, paths in existing_nodes_with_paths.items(): 'node_id': node_id,
for p_str in paths: 'provider': attr_provider,
all_sources.append({'node_id': node_id, 'path': p_str}) 'attribute': attr_name,
'path': f"{attr_provider}_{attr_name}"
}
all_correlations.append({ # Add source if not already present (avoid duplicates)
'value': value, existing_sources = [s for s in self.correlation_index[attr_value]['sources']
'sources': all_sources, if s['node_id'] == node_id and s['path'] == source_info['path']]
'nodes': list(unique_nodes) if not existing_sources:
}) self.correlation_index[attr_value]['sources'].append(source_info)
return all_correlations
def add_node(self, node_id: str, node_type: NodeType, attributes: Optional[Dict[str, Any]] = None, # Create correlation node if we have multiple nodes with this value
description: str = "", metadata: Optional[Dict[str, Any]] = None) -> bool: if len(self.correlation_index[attr_value]['nodes']) > 1:
"""Add a node to the graph, update attributes, and process correlations.""" self._create_enhanced_correlation_node_and_edges(attr_value, self.correlation_index[attr_value])
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
if attributes and node_type != NodeType.CORRELATION_OBJECT: def _create_enhanced_correlation_node_and_edges(self, value, correlation_data):
correlations = self._check_for_correlations(node_id, attributes)
for corr in correlations:
value = corr['value']
# STEP 1: Substring check against all existing nodes
if self._correlation_value_matches_existing_node(value):
# Skip creating correlation node - would be redundant
continue
# STEP 2: Filter out node pairs that already have direct edges
eligible_nodes = self._filter_nodes_without_direct_edges(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
for c_node_id in eligible_nodes:
if self.graph.has_node(c_node_id):
attribute = corr['sources'][0]['path'].split('.')[-1]
relationship_type = f"c_{attribute}"
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 _filter_nodes_without_direct_edges(self, node_set: set) -> set:
""" """
Filter out nodes that already have direct edges between them. UPDATED: Create correlation node and edges with raw provider data (no formatting).
Returns set of nodes that should be included in correlation.
""" """
nodes_list = list(node_set) correlation_node_id = f"corr_{hash(str(value)) & 0x7FFFFFFF}"
eligible_nodes = set(node_set) # Start with all nodes nodes = correlation_data['nodes']
sources = correlation_data['sources']
# Check all pairs of nodes # Create or update correlation node
for i in range(len(nodes_list)): if not self.graph.has_node(correlation_node_id):
for j in range(i + 1, len(nodes_list)): # Use raw provider/attribute data - no formatting
node_a = nodes_list[i] provider_counts = {}
node_b = nodes_list[j] for source in sources:
# Keep original provider and attribute names
key = f"{source['provider']}_{source['attribute']}"
provider_counts[key] = provider_counts.get(key, 0) + 1
# Check if direct edge exists in either direction # Use the most common provider/attribute as the primary label (raw)
if self._has_direct_edge_bidirectional(node_a, node_b): primary_source = max(provider_counts.items(), key=lambda x: x[1])[0] if provider_counts else "unknown_correlation"
# Remove both nodes from eligible set since they're already connected
eligible_nodes.discard(node_a) metadata = {
eligible_nodes.discard(node_b) 'value': value,
'correlated_nodes': list(nodes),
'sources': sources,
'primary_source': primary_source,
'correlation_count': len(nodes)
}
self.add_node(correlation_node_id, NodeType.CORRELATION_OBJECT, metadata=metadata)
#print(f"Created correlation node {correlation_node_id} for value '{value}' with {len(nodes)} nodes")
# Create edges from each node to the correlation node
for source in sources:
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})")
return eligible_nodes
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:
""" """
Check if there's a direct edge between two nodes in either direction. Check if there's a direct edge between two nodes in either direction.
Returns True if node_anode_b OR node_bnode_a exists. Returns True if node_aâ†'node_b OR node_bâ†'node_a exists.
""" """
return (self.graph.has_edge(node_a, node_b) or return (self.graph.has_edge(node_a, node_b) or
self.graph.has_edge(node_b, node_a)) self.graph.has_edge(node_b, node_a))
@@ -290,7 +281,7 @@ class GraphManager:
# Create set of unique sources based on (node_id, path) tuples # Create set of unique sources based on (node_id, path) tuples
source_set = set() source_set = set()
for source in existing_sources + new_sources: for source in existing_sources + new_sources:
source_tuple = (source['node_id'], source['path']) source_tuple = (source['node_id'], source.get('path', ''))
source_set.add(source_tuple) source_set.add(source_tuple)
# Convert back to list of dictionaries # Convert back to list of dictionaries
@@ -313,19 +304,60 @@ 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:
"""Add or update an edge between two nodes, ensuring nodes exist.""" """
UPDATED: Add or update an edge between two nodes with raw relationship labels.
"""
if not self.graph.has_node(source_id) or not self.graph.has_node(target_id): if not self.graph.has_node(source_id) or not self.graph.has_node(target_id):
return False return False
new_confidence = confidence_score new_confidence = confidence_score
if relationship_type.startswith("c_"): # UPDATED: Use raw relationship type - no formatting
edge_label = relationship_type edge_label = relationship_type
else:
edge_label = f"{source_provider}_{relationship_type}"
if self.graph.has_edge(source_id, target_id): if self.graph.has_edge(source_id, target_id):
# If edge exists, update confidence if the new score is higher. # If edge exists, update confidence if the new score is higher.
@@ -335,7 +367,7 @@ class GraphManager:
self.graph.edges[source_id, target_id]['updated_by'] = source_provider self.graph.edges[source_id, target_id]['updated_by'] = source_provider
return False return False
# Add a new edge with all attributes. # Add a new edge with raw attributes
self.graph.add_edge(source_id, target_id, self.graph.add_edge(source_id, target_id,
relationship_type=edge_label, relationship_type=edge_label,
confidence_score=new_confidence, confidence_score=new_confidence,
@@ -345,6 +377,69 @@ class GraphManager:
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', [])
# Find the 'nodes' attribute dictionary in the list
nodes_attr = next((attr for attr in attributes if attr.get('name') == 'nodes'), None)
# Remove from the list of member nodes
if nodes_attr and 'value' in nodes_attr and isinstance(nodes_attr['value'], list) and node_id_to_extract in nodes_attr['value']:
nodes_attr['value'].remove(node_id_to_extract)
# Find the 'count' attribute and update it
count_attr = next((attr for attr in attributes if attr.get('name') == 'count'), None)
if count_attr:
count_attr['value'] = len(nodes_attr['value'])
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."""
return self.graph.number_of_nodes() return self.graph.number_of_nodes()
@@ -357,54 +452,59 @@ class GraphManager:
"""Get all nodes of a specific type.""" """Get all nodes of a specific type."""
return [n for n, d in self.graph.nodes(data=True) if d.get('type') == node_type.value] return [n for n, d in self.graph.nodes(data=True) if d.get('type') == node_type.value]
def get_neighbors(self, node_id: str) -> List[str]:
"""Get all unique neighbors (predecessors and successors) for a node."""
if not self.graph.has_node(node_id):
return []
return list(set(self.graph.predecessors(node_id)) | set(self.graph.successors(node_id)))
def get_high_confidence_edges(self, min_confidence: float = 0.8) -> List[Tuple[str, str, Dict]]: def get_high_confidence_edges(self, min_confidence: float = 0.8) -> List[Tuple[str, str, Dict]]:
"""Get edges with confidence score above a given threshold.""" """Get edges with confidence score above a given threshold."""
return [(u, v, d) for u, v, d in self.graph.edges(data=True) return [(u, v, d) for u, v, d in self.graph.edges(data=True)
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.
SIMPLIFIED: No certificate styling - frontend handles all visual styling.
"""
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 = {
'attributes': attrs.get('attributes', {}), 'id': node_id,
'description': attrs.get('description', ''), 'label': node_id,
'metadata': attrs.get('metadata', {}), 'type': attrs.get('type', 'unknown'),
'added_timestamp': attrs.get('added_timestamp')} 'attributes': attrs.get('attributes', []), # Raw attributes list
# Customize node appearance based on type and attributes 'description': attrs.get('description', ''),
node_type = node_data['type'] 'metadata': attrs.get('metadata', {}),
attributes = node_data['attributes'] 'added_timestamp': attrs.get('added_timestamp')
if node_type == 'domain' and attributes.get('certificates', {}).get('has_valid_cert') is False: }
node_data['color'] = {'background': '#c7c7c7', 'border': '#999'} # Gray for invalid cert
# 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):
node_data['incoming_edges'] = [{'from': u, 'data': d} for u, _, d in self.graph.in_edges(node_id, data=True)] node_data['incoming_edges'] = [
node_data['outgoing_edges'] = [{'to': v, 'data': d} for _, v, d in self.graph.out_edges(node_id, data=True)] {'from': u, 'data': d} for u, _, d in self.graph.in_edges(node_id, data=True)
]
node_data['outgoing_edges'] = [
{'to': v, 'data': d} for _, v, d in self.graph.out_edges(node_id, data=True)
]
nodes.append(node_data) nodes.append(node_data)
edges = [] edges = []
for source, target, attrs in self.graph.edges(data=True): for source, target, attrs in self.graph.edges(data=True):
edges.append({'from': source, 'to': target, edges.append({
'label': attrs.get('relationship_type', ''), 'from': source,
'confidence_score': attrs.get('confidence_score', 0), 'to': target,
'source_provider': attrs.get('source_provider', ''), 'label': attrs.get('relationship_type', ''),
'discovery_timestamp': attrs.get('discovery_timestamp')}) 'confidence_score': attrs.get('confidence_score', 0),
'source_provider': attrs.get('source_provider', ''),
'discovery_timestamp': attrs.get('discovery_timestamp')
})
return { return {
'nodes': nodes, 'edges': edges, 'nodes': nodes,
'edges': edges,
'statistics': self.get_statistics()['basic_metrics'] 'statistics': self.get_statistics()['basic_metrics']
} }
def export_json(self) -> Dict[str, Any]: def export_json(self) -> Dict[str, Any]:
"""Export complete graph data as a JSON-serializable dictionary.""" """Export complete graph data as a JSON-serializable dictionary."""
graph_data = nx.node_link_data(self.graph) # Use NetworkX's built-in robust serializer graph_data = nx.node_link_data(self.graph, edges="edges")
return { return {
'export_metadata': { 'export_metadata': {
'export_timestamp': datetime.now(timezone.utc).isoformat(), 'export_timestamp': datetime.now(timezone.utc).isoformat(),
@@ -412,37 +512,67 @@ 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_nodeling' 'graph_format': 'dnsrecon_v1_unified_model'
}, },
'graph': graph_data, 'graph': graph_data,
'statistics': self.get_statistics() 'statistics': self.get_statistics()
} }
def _get_confidence_distribution(self) -> Dict[str, int]: def _get_confidence_distribution(self) -> Dict[str, int]:
"""Get distribution of edge confidence scores.""" """Get distribution of edge confidence scores with empty graph handling."""
distribution = {'high': 0, 'medium': 0, 'low': 0} distribution = {'high': 0, 'medium': 0, 'low': 0}
for _, _, confidence in self.graph.edges(data='confidence_score', default=0):
if confidence >= 0.8: distribution['high'] += 1 # FIXED: Handle empty graph case
elif confidence >= 0.6: distribution['medium'] += 1 if self.get_edge_count() == 0:
else: distribution['low'] += 1 return distribution
for _, _, data in self.graph.edges(data=True):
confidence = data.get('confidence_score', 0)
if confidence >= 0.8:
distribution['high'] += 1
elif confidence >= 0.6:
distribution['medium'] += 1
else:
distribution['low'] += 1
return distribution return distribution
def get_statistics(self) -> Dict[str, Any]: def get_statistics(self) -> Dict[str, Any]:
"""Get comprehensive statistics about the graph.""" """Get comprehensive statistics about the graph with proper empty graph handling."""
stats = {'basic_metrics': {'total_nodes': self.get_node_count(),
'total_edges': self.get_edge_count(), # FIXED: Handle empty graph case properly
'creation_time': self.creation_time, node_count = self.get_node_count()
'last_modified': self.last_modified}, edge_count = self.get_edge_count()
'node_type_distribution': {}, 'relationship_type_distribution': {},
'confidence_distribution': self._get_confidence_distribution(), stats = {
'provider_distribution': {}} 'basic_metrics': {
# Calculate distributions 'total_nodes': node_count,
for node_type in NodeType: 'total_edges': edge_count,
stats['node_type_distribution'][node_type.value] = self.get_nodes_by_type(node_type).__len__() 'creation_time': self.creation_time,
for _, _, rel_type in self.graph.edges(data='relationship_type', default='unknown'): 'last_modified': self.last_modified
stats['relationship_type_distribution'][rel_type] = stats['relationship_type_distribution'].get(rel_type, 0) + 1 },
for _, _, provider in self.graph.edges(data='source_provider', default='unknown'): 'node_type_distribution': {},
stats['provider_distribution'][provider] = stats['provider_distribution'].get(provider, 0) + 1 'relationship_type_distribution': {},
'confidence_distribution': self._get_confidence_distribution(),
'provider_distribution': {}
}
# FIXED: Only calculate distributions if we have data
if node_count > 0:
# Calculate node type distributions
for node_type in NodeType:
count = len(self.get_nodes_by_type(node_type))
if count > 0: # Only include types that exist
stats['node_type_distribution'][node_type.value] = count
if edge_count > 0:
# Calculate edge distributions
for _, _, data in self.graph.edges(data=True):
rel_type = data.get('relationship_type', 'unknown')
stats['relationship_type_distribution'][rel_type] = stats['relationship_type_distribution'].get(rel_type, 0) + 1
provider = data.get('source_provider', 'unknown')
stats['provider_distribution'][provider] = stats['provider_distribution'].get(provider, 0) + 1
return stats return stats
def clear(self) -> None: def clear(self) -> None:

View File

@@ -42,7 +42,7 @@ class ForensicLogger:
Maintains detailed audit trail of all reconnaissance activities. Maintains detailed audit trail of all reconnaissance activities.
""" """
def __init__(self, session_id: str = None): def __init__(self, session_id: str = ""):
""" """
Initialize forensic logger. Initialize forensic logger.
@@ -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,6 +86,8 @@ 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):
@@ -101,6 +103,7 @@ 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."""
@@ -149,7 +152,7 @@ class ForensicLogger:
# Log to standard logger # Log to standard logger
if error: if error:
self.logger.error(f"API Request Failed - {provider}: {url} - {error}") self.logger.error(f"API Request Failed.")
else: else:
self.logger.info(f"API Request - {provider}: {url} - Status: {status_code}") self.logger.info(f"API Request - {provider}: {url} - Status: {status_code}")
@@ -194,7 +197,7 @@ class ForensicLogger:
self.logger.info(f"Scan Started - Target: {target_domain}, Depth: {recursion_depth}") self.logger.info(f"Scan Started - Target: {target_domain}, Depth: {recursion_depth}")
self.logger.info(f"Enabled Providers: {', '.join(enabled_providers)}") self.logger.info(f"Enabled Providers: {', '.join(enabled_providers)}")
self.session_metadata['target_domains'].add(target_domain) self.session_metadata['target_domains'].update(target_domain)
def log_scan_complete(self) -> None: def log_scan_complete(self) -> None:
"""Log the completion of a reconnaissance scan.""" """Log the completion of a reconnaissance scan."""
@@ -203,8 +206,6 @@ class ForensicLogger:
self.session_metadata['target_domains'] = list(self.session_metadata['target_domains']) self.session_metadata['target_domains'] = list(self.session_metadata['target_domains'])
self.logger.info(f"Scan Complete - Session: {self.session_id}") self.logger.info(f"Scan Complete - Session: {self.session_id}")
self.logger.info(f"Total API Requests: {self.session_metadata['total_requests']}")
self.logger.info(f"Total Relationships: {self.session_metadata['total_relationships']}")
def export_audit_trail(self) -> Dict[str, Any]: def export_audit_trail(self) -> Dict[str, Any]:
""" """

107
core/provider_result.py Normal file
View File

@@ -0,0 +1,107 @@
# 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)
##TODO
#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

28
core/rate_limiter.py Normal file
View File

@@ -0,0 +1,28 @@
# 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

File diff suppressed because it is too large Load Diff

View File

@@ -3,11 +3,9 @@ Per-session configuration management for DNSRecon.
Provides isolated configuration instances for each user session. Provides isolated configuration instances for each user session.
""" """
import os from config import Config
from typing import Dict, Optional
class SessionConfig(Config):
class SessionConfig:
""" """
Session-specific configuration that inherits from global config Session-specific configuration that inherits from global config
but maintains isolated API keys and provider settings. but maintains isolated API keys and provider settings.
@@ -15,106 +13,8 @@ class SessionConfig:
def __init__(self): def __init__(self):
"""Initialize session config with global defaults.""" """Initialize session config with global defaults."""
# Copy all attributes from global config super().__init__()
self.api_keys: Dict[str, Optional[str]] = {
'shodan': None
}
# Default settings (copied from global config) def create_session_config() -> 'SessionConfig':
self.default_recursion_depth = 2
self.default_timeout = 30
self.max_concurrent_requests = 5
self.large_entity_threshold = 100
# Rate limiting settings (per session)
self.rate_limits = {
'crtsh': 60,
'shodan': 60,
'dns': 100
}
# Provider settings (per session)
self.enabled_providers = {
'crtsh': True,
'dns': True,
'shodan': False
}
# Logging configuration
self.log_level = 'INFO'
self.log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
# Flask configuration (shared)
self.flask_host = '127.0.0.1'
self.flask_port = 5000
self.flask_debug = True
def set_api_key(self, provider: str, api_key: str) -> bool:
"""
Set API key for a provider in this session.
Args:
provider: Provider name (shodan, etc)
api_key: API key string
Returns:
bool: True if key was set successfully
"""
if provider in self.api_keys:
self.api_keys[provider] = api_key
self.enabled_providers[provider] = True if api_key else False
return True
return False
def get_api_key(self, provider: str) -> Optional[str]:
"""
Get API key for a provider in this session.
Args:
provider: Provider name
Returns:
API key or None if not set
"""
return self.api_keys.get(provider)
def is_provider_enabled(self, provider: str) -> bool:
"""
Check if a provider is enabled in this session.
Args:
provider: Provider name
Returns:
bool: True if provider is enabled
"""
return self.enabled_providers.get(provider, False)
def get_rate_limit(self, provider: str) -> int:
"""
Get rate limit for a provider in this session.
Args:
provider: Provider name
Returns:
Rate limit in requests per minute
"""
return self.rate_limits.get(provider, 60)
def load_from_env(self):
"""Load configuration from environment variables (only if not already set)."""
if os.getenv('SHODAN_API_KEY') and not self.api_keys['shodan']:
self.set_api_key('shodan', os.getenv('SHODAN_API_KEY'))
# Override default settings from environment
self.default_recursion_depth = int(os.getenv('DEFAULT_RECURSION_DEPTH', '2'))
self.default_timeout = 30
self.max_concurrent_requests = 5
def create_session_config() -> SessionConfig:
"""Create a new session configuration instance.""" """Create a new session configuration instance."""
session_config = SessionConfig() return SessionConfig()
session_config.load_from_env()
return session_config

View File

@@ -5,26 +5,30 @@ import time
import uuid import uuid
import redis import redis
import pickle import pickle
from typing import Dict, Optional, Any, List from typing import Dict, Optional, Any
from core.scanner import Scanner from core.scanner import Scanner
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. FIXED: Manages multiple scanner instances for concurrent user sessions using Redis.
Now more conservative about session creation to preserve API keys and configuration.
""" """
def __init__(self, session_timeout_minutes: int = 60): def __init__(self, session_timeout_minutes: int = 0):
""" """
Initialize session manager with a Redis backend. Initialize session manager with a Redis backend.
""" """
if session_timeout_minutes is None:
session_timeout_minutes = config.session_timeout_minutes
self.redis_client = redis.StrictRedis(db=0, decode_responses=False) self.redis_client = redis.StrictRedis(db=0, decode_responses=False)
self.session_timeout = session_timeout_minutes * 60 # Convert to seconds self.session_timeout = session_timeout_minutes * 60 # Convert to seconds
self.lock = threading.Lock() # Lock for local operations, Redis handles atomic ops self.lock = threading.Lock()
# FIXED: Add a creation lock to prevent race conditions
self.creation_lock = threading.Lock()
# Start cleanup thread # Start cleanup thread
self.cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True) self.cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True)
@@ -36,7 +40,7 @@ class SessionManager:
"""Prepare SessionManager for pickling.""" """Prepare SessionManager for pickling."""
state = self.__dict__.copy() state = self.__dict__.copy()
# Exclude unpickleable attributes - Redis client and threading objects # Exclude unpickleable attributes - Redis client and threading objects
unpicklable_attrs = ['lock', 'cleanup_thread', 'redis_client'] unpicklable_attrs = ['lock', 'cleanup_thread', 'redis_client', 'creation_lock']
for attr in unpicklable_attrs: for attr in unpicklable_attrs:
if attr in state: if attr in state:
del state[attr] del state[attr]
@@ -46,9 +50,9 @@ class SessionManager:
"""Restore SessionManager after unpickling.""" """Restore SessionManager after unpickling."""
self.__dict__.update(state) self.__dict__.update(state)
# Re-initialize unpickleable attributes # Re-initialize unpickleable attributes
import redis
self.redis_client = redis.StrictRedis(db=0, decode_responses=False) self.redis_client = redis.StrictRedis(db=0, decode_responses=False)
self.lock = threading.Lock() self.lock = threading.Lock()
self.creation_lock = threading.Lock()
self.cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True) self.cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True)
self.cleanup_thread.start() self.cleanup_thread.start()
@@ -62,44 +66,47 @@ class SessionManager:
def create_session(self) -> str: def create_session(self) -> str:
""" """
Create a new user session and store it in Redis. FIXED: Create a new user session with thread-safe creation to prevent duplicates.
""" """
session_id = str(uuid.uuid4()) # FIXED: Use creation lock to prevent race conditions
print(f"=== CREATING SESSION {session_id} IN REDIS ===") with self.creation_lock:
session_id = str(uuid.uuid4())
print(f"=== CREATING SESSION {session_id} IN REDIS ===")
try: try:
from core.session_config import create_session_config from core.session_config import create_session_config
session_config = create_session_config() session_config = create_session_config()
scanner_instance = Scanner(session_config=session_config) scanner_instance = Scanner(session_config=session_config)
# Set the session ID on the scanner for cross-process stop signal management # Set the session ID on the scanner for cross-process stop signal management
scanner_instance.session_id = session_id scanner_instance.session_id = session_id
session_data = { session_data = {
'scanner': scanner_instance, 'scanner': scanner_instance,
'config': session_config, 'config': session_config,
'created_at': time.time(), 'created_at': time.time(),
'last_activity': time.time(), 'last_activity': time.time(),
'status': 'active' 'status': 'active'
} }
# Serialize the entire session data dictionary using pickle # Serialize the entire session data dictionary using pickle
serialized_data = pickle.dumps(session_data) serialized_data = pickle.dumps(session_data)
# Store in Redis # Store in Redis
session_key = self._get_session_key(session_id) session_key = self._get_session_key(session_id)
self.redis_client.setex(session_key, self.session_timeout, serialized_data) self.redis_client.setex(session_key, self.session_timeout, serialized_data)
# Initialize stop signal as False # Initialize stop signal as False
stop_key = self._get_stop_signal_key(session_id) stop_key = self._get_stop_signal_key(session_id)
self.redis_client.setex(stop_key, self.session_timeout, b'0') self.redis_client.setex(stop_key, self.session_timeout, b'0')
print(f"Session {session_id} stored in Redis with stop signal initialized") print(f"Session {session_id} stored in Redis with stop signal initialized")
return session_id print(f"Session has {len(scanner_instance.providers)} providers: {[p.get_name() for p in scanner_instance.providers]}")
return session_id
except Exception as e: except Exception as e:
print(f"ERROR: Failed to create session {session_id}: {e}") print(f"ERROR: Failed to create session {session_id}: {e}")
raise raise
def set_stop_signal(self, session_id: str) -> bool: def set_stop_signal(self, session_id: str) -> bool:
""" """
@@ -208,7 +215,14 @@ class SessionManager:
# Immediately save to Redis for GUI updates # Immediately save to Redis for GUI updates
success = self._save_session_data(session_id, session_data) success = self._save_session_data(session_id, session_data)
if success: if success:
print(f"Scanner state updated for session {session_id} (status: {scanner.status})") # Only log occasionally to reduce noise
if hasattr(self, '_last_update_log'):
if time.time() - self._last_update_log > 5: # Log every 5 seconds max
#print(f"Scanner state updated for session {session_id} (status: {scanner.status})")
self._last_update_log = time.time()
else:
#print(f"Scanner state updated for session {session_id} (status: {scanner.status})")
self._last_update_log = time.time()
else: else:
print(f"WARNING: Failed to save scanner state for session {session_id}") print(f"WARNING: Failed to save scanner state for session {session_id}")
return success return success
@@ -355,31 +369,6 @@ class SessionManager:
time.sleep(300) # Sleep for 5 minutes time.sleep(300) # Sleep for 5 minutes
def list_active_sessions(self) -> List[Dict[str, Any]]:
"""List all active sessions for admin purposes."""
try:
session_keys = self.redis_client.keys("dnsrecon:session:*")
sessions = []
for session_key in session_keys:
session_id = session_key.decode('utf-8').split(':')[-1]
session_data = self._get_session_data(session_id)
if session_data:
scanner = session_data.get('scanner')
sessions.append({
'session_id': session_id,
'created_at': session_data.get('created_at'),
'last_activity': session_data.get('last_activity'),
'scanner_status': scanner.status if scanner else 'unknown',
'current_target': scanner.current_target if scanner else None
})
return sessions
except Exception as e:
print(f"ERROR: Failed to list active sessions: {e}")
return []
def get_statistics(self) -> Dict[str, Any]: def get_statistics(self) -> Dict[str, Any]:
"""Get session manager statistics.""" """Get session manager statistics."""
try: try:

BIN
dump.rdb

Binary file not shown.

View File

@@ -3,14 +3,15 @@ Data provider modules for DNSRecon.
Contains implementations for various reconnaissance data sources. Contains implementations for various reconnaissance data sources.
""" """
from .base_provider import BaseProvider, RateLimiter from .base_provider import BaseProvider
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',
'RateLimiter', 'GlobalRateLimiter',
'CrtShProvider', 'CrtShProvider',
'DNSProvider', 'DNSProvider',
'ShodanProvider' 'ShodanProvider'

View File

@@ -3,52 +3,18 @@
import time import time
import requests import requests
import threading import threading
import os
import json
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List, Dict, Any, Optional, Tuple from typing import Dict, Any, Optional
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. Now supports session-specific configuration and returns standardized ProviderResult objects.
""" """
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):
@@ -70,30 +36,20 @@ 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()
self._stop_event = None self._stop_event = None
# Caching configuration (per session)
self.cache_dir = f'.cache/{id(self.config)}' # Unique cache per session config
self.cache_expiry = 12 * 3600 # 12 hours in seconds
if not os.path.exists(self.cache_dir):
os.makedirs(self.cache_dir)
# Statistics (per provider instance) # Statistics (per provider instance)
self.total_requests = 0 self.total_requests = 0
self.successful_requests = 0 self.successful_requests = 0
self.failed_requests = 0 self.failed_requests = 0
self.total_relationships_found = 0 self.total_relationships_found = 0
print(f"Initialized {name} provider with session-specific config (rate: {actual_rate_limit}/min)")
def __getstate__(self): def __getstate__(self):
"""Prepare BaseProvider for pickling by excluding unpicklable objects.""" """Prepare BaseProvider for pickling by excluding unpicklable objects."""
state = self.__dict__.copy() state = self.__dict__.copy()
@@ -146,7 +102,7 @@ class BaseProvider(ABC):
pass pass
@abstractmethod @abstractmethod
def query_domain(self, domain: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]: def query_domain(self, domain: str) -> ProviderResult:
""" """
Query the provider for information about a domain. Query the provider for information about a domain.
@@ -154,12 +110,12 @@ class BaseProvider(ABC):
domain: Domain to investigate domain: Domain to investigate
Returns: Returns:
List of tuples: (source_node, target_node, relationship_type, confidence, raw_data) ProviderResult containing standardized attributes and relationships
""" """
pass pass
@abstractmethod @abstractmethod
def query_ip(self, ip: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]: def query_ip(self, ip: str) -> ProviderResult:
""" """
Query the provider for information about an IP address. Query the provider for information about an IP address.
@@ -167,178 +123,92 @@ class BaseProvider(ABC):
ip: IP address to investigate ip: IP address to investigate
Returns: Returns:
List of tuples: (source_node, target_node, relationship_type, confidence, raw_data) ProviderResult containing standardized attributes and relationships
""" """
pass pass
def make_request(self, url: str, method: str = "GET", def make_request(self, url: str, method: str = "GET",
params: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None, headers: Optional[Dict[str, str]] = None,
target_indicator: str = "", target_indicator: str = "") -> Optional[requests.Response]:
max_retries: int = 3) -> Optional[requests.Response]:
""" """
Make a rate-limited HTTP request with aggressive stop signal handling. Make a rate-limited HTTP request.
Terminates immediately when stop is requested, including during retries. FIXED: Returns response without automatically raising HTTPError exceptions.
Individual providers should handle status codes appropriately.
""" """
# Check for cancellation before starting
if self._is_stop_requested(): if self._is_stop_requested():
print(f"Request cancelled before start: {url}") print(f"Request cancelled before start: {url}")
return None return None
# Create a unique cache key start_time = time.time()
cache_key = f"{self.name}_{hash(f'{method}:{url}:{json.dumps(params, sort_keys=True)}')}.json" response = None
cache_path = os.path.join(self.cache_dir, cache_key) error = None
# Check cache try:
if os.path.exists(cache_path): self.total_requests += 1
cache_age = time.time() - os.path.getmtime(cache_path)
if cache_age < self.cache_expiry:
print(f"Returning cached response for: {url}")
with open(cache_path, 'r') as f:
cached_data = json.load(f)
response = requests.Response()
response.status_code = cached_data['status_code']
response._content = cached_data['content'].encode('utf-8')
response.headers = cached_data['headers']
return response
# Determine effective max_retries based on stop signal request_headers = dict(self.session.headers).copy()
effective_max_retries = 0 if self._is_stop_requested() else max_retries if headers:
last_exception = None request_headers.update(headers)
for attempt in range(effective_max_retries + 1): print(f"Making {method} request to: {url}")
# AGGRESSIVE: Check for cancellation before each attempt
if self._is_stop_requested():
print(f"Request cancelled during attempt {attempt + 1}: {url}")
return None
# Apply rate limiting with cancellation awareness if method.upper() == "GET":
if not self._wait_with_cancellation_check(): response = self.session.get(
print(f"Request cancelled during rate limiting: {url}") url,
return None params=params,
headers=request_headers,
# AGGRESSIVE: Final check before making HTTP request timeout=self.timeout
if self._is_stop_requested():
print(f"Request cancelled before HTTP call: {url}")
return None
start_time = time.time()
response = None
error = None
try:
self.total_requests += 1
# Prepare request
request_headers = self.session.headers.copy()
if headers:
request_headers.update(headers)
print(f"Making {method} request to: {url} (attempt {attempt + 1})")
# AGGRESSIVE: Use much shorter timeout if termination is requested
request_timeout = self.timeout
if self._is_stop_requested():
request_timeout = 2 # Max 2 seconds if termination requested
print(f"Stop requested - using short timeout: {request_timeout}s")
# Make request
if method.upper() == "GET":
response = self.session.get(
url,
params=params,
headers=request_headers,
timeout=request_timeout
)
elif method.upper() == "POST":
response = self.session.post(
url,
json=params,
headers=request_headers,
timeout=request_timeout
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
print(f"Response status: {response.status_code}")
response.raise_for_status()
self.successful_requests += 1
# Success - log, cache, and return
duration_ms = (time.time() - start_time) * 1000
self.logger.log_api_request(
provider=self.name,
url=url,
method=method.upper(),
status_code=response.status_code,
response_size=len(response.content),
duration_ms=duration_ms,
error=None,
target_indicator=target_indicator
) )
# Cache the successful response to disk elif method.upper() == "POST":
with open(cache_path, 'w') as f: response = self.session.post(
json.dump({ url,
'status_code': response.status_code, json=params,
'content': response.text, headers=request_headers,
'headers': dict(response.headers) timeout=self.timeout
}, f) )
return response else:
raise ValueError(f"Unsupported HTTP method: {method}")
except requests.exceptions.RequestException as e: print(f"Response status: {response.status_code}")
error = str(e)
# FIXED: Don't automatically raise for HTTP error status codes
# Let individual providers handle status codes appropriately
# Only count 2xx responses as successful
if 200 <= response.status_code < 300:
self.successful_requests += 1
else:
self.failed_requests += 1 self.failed_requests += 1
print(f"Request failed (attempt {attempt + 1}): {error}")
last_exception = e
# AGGRESSIVE: Immediately abort retries if stop requested duration_ms = (time.time() - start_time) * 1000
if self._is_stop_requested(): self.logger.log_api_request(
print(f"Stop requested - aborting retries for: {url}") provider=self.name,
break url=url,
method=method.upper(),
status_code=response.status_code,
response_size=len(response.content),
duration_ms=duration_ms,
error=None,
target_indicator=target_indicator
)
# Check if we should retry (but only if stop not requested) return response
if attempt < effective_max_retries and self._should_retry(e):
# Use a longer, more respectful backoff for 429 errors
if isinstance(e, requests.exceptions.HTTPError) and e.response and e.response.status_code == 429:
# Start with a 10-second backoff and increase exponentially
backoff_time = 10 * (2 ** attempt)
print(f"Rate limit hit. Retrying in {backoff_time} seconds...")
else:
backoff_time = min(1.0, (2 ** attempt) * 0.5) # Shorter backoff for other errors
print(f"Retrying in {backoff_time} seconds...")
# AGGRESSIVE: Much shorter backoff and more frequent checking except requests.exceptions.RequestException as e:
if not self._sleep_with_cancellation_check(backoff_time): error = str(e)
print(f"Stop requested during backoff - aborting: {url}") self.failed_requests += 1
return None duration_ms = (time.time() - start_time) * 1000
continue self.logger.log_api_request(
else: provider=self.name,
break url=url,
method=method.upper(),
except Exception as e: status_code=response.status_code if response else None,
error = f"Unexpected error: {str(e)}" response_size=len(response.content) if response else None,
self.failed_requests += 1 duration_ms=duration_ms,
print(f"Unexpected error: {error}") error=error,
last_exception = e target_indicator=target_indicator
break )
raise e
# All attempts failed - log and return None
duration_ms = (time.time() - start_time) * 1000
self.logger.log_api_request(
provider=self.name,
url=url,
method=method.upper(),
status_code=response.status_code if response else None,
response_size=len(response.content) if response else None,
duration_ms=duration_ms,
error=error,
target_indicator=target_indicator
)
if error and last_exception:
raise last_exception
return None
def _is_stop_requested(self) -> bool: def _is_stop_requested(self) -> bool:
""" """
@@ -348,44 +218,6 @@ class BaseProvider(ABC):
return True return True
return False return False
def _wait_with_cancellation_check(self) -> bool:
"""
Wait for rate limiting while aggressively checking for cancellation.
Returns False if cancelled during wait.
"""
current_time = time.time()
time_since_last = current_time - self.rate_limiter.last_request_time
if time_since_last < self.rate_limiter.min_interval:
sleep_time = self.rate_limiter.min_interval - time_since_last
if not self._sleep_with_cancellation_check(sleep_time):
return False
self.rate_limiter.last_request_time = time.time()
return True
def _sleep_with_cancellation_check(self, sleep_time: float) -> bool:
"""
Sleep for the specified time while aggressively checking for cancellation.
Args:
sleep_time: Time to sleep in seconds
Returns:
bool: True if sleep completed, False if cancelled
"""
sleep_start = time.time()
check_interval = 0.05 # Check every 50ms for aggressive responsiveness
while time.time() - sleep_start < sleep_time:
if self._is_stop_requested():
return False
remaining_time = sleep_time - (time.time() - sleep_start)
time.sleep(min(check_interval, remaining_time))
return True
def set_stop_event(self, stop_event: threading.Event) -> None: def set_stop_event(self, stop_event: threading.Event) -> None:
""" """
Set the stop event for this provider to enable cancellation. Set the stop event for this provider to enable cancellation.
@@ -395,28 +227,6 @@ class BaseProvider(ABC):
""" """
self._stop_event = stop_event self._stop_event = stop_event
def _should_retry(self, exception: requests.exceptions.RequestException) -> bool:
"""
Determine if a request should be retried based on the exception.
Args:
exception: The request exception that occurred
Returns:
True if the request should be retried
"""
# Retry on connection errors and timeouts
if isinstance(exception, (requests.exceptions.ConnectionError,
requests.exceptions.Timeout)):
return True
if isinstance(exception, requests.exceptions.HTTPError):
if hasattr(exception, 'response') and exception.response:
# Retry on server errors (5xx) AND on rate-limiting errors (429)
return exception.response.status_code >= 500 or exception.response.status_code == 429
return False
def log_relationship_discovery(self, source_node: str, target_node: str, def log_relationship_discovery(self, source_node: str, target_node: str,
relationship_type: str, relationship_type: str,
confidence_score: float, confidence_score: float,
@@ -459,5 +269,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.rate_limiter.requests_per_minute 'rate_limit': self.config.get_rate_limit(self.name)
} }

View File

@@ -1,27 +1,26 @@
""" # dnsrecon/providers/crtsh_provider.py
Certificate Transparency provider using crt.sh.
Discovers domain relationships through certificate SAN analysis with comprehensive certificate tracking.
Stores certificates as metadata on domain nodes rather than creating certificate nodes.
"""
import json import json
import re import re
from typing import List, Dict, Any, Tuple, Set from pathlib import Path
from typing import List, Dict, Any, Set
from urllib.parse import quote from urllib.parse import quote
from datetime import datetime, timezone from datetime import datetime, timezone
import requests import requests
from .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
class CrtShProvider(BaseProvider): class CrtShProvider(BaseProvider):
""" """
Provider for querying crt.sh certificate transparency database. Provider for querying crt.sh certificate transparency database.
Now uses session-specific configuration and caching. FIXED: Now properly creates domain and CA nodes instead of large entities.
Returns standardized ProviderResult objects with caching support.
""" """
def __init__(self, session_config=None): def __init__(self, name=None, session_config=None):
"""Initialize CrtSh provider with session-specific configuration.""" """Initialize CrtSh provider with session-specific configuration."""
super().__init__( super().__init__(
name="crtsh", name="crtsh",
@@ -32,6 +31,13 @@ class CrtShProvider(BaseProvider):
self.base_url = "https://crt.sh/" self.base_url = "https://crt.sh/"
self._stop_event = None self._stop_event = None
# Initialize cache directory (separate from BaseProvider's HTTP cache)
self.domain_cache_dir = Path('cache') / 'crtsh'
self.domain_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"
@@ -49,100 +55,359 @@ 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 is configured to be used.
This method is intentionally simple and does not perform a network request
to avoid blocking application startup.
"""
return True return True
def _parse_certificate_date(self, date_string: str) -> datetime: def _get_cache_file_path(self, domain: str) -> Path:
""" """Generate cache file path for a domain."""
Parse certificate date from crt.sh format. safe_domain = domain.replace('.', '_').replace('/', '_').replace('\\', '_')
return self.domain_cache_dir / f"{safe_domain}.json"
Args: def _get_cache_status(self, cache_file_path: Path) -> str:
date_string: Date string from crt.sh API
Returns:
Parsed datetime object in UTC
""" """
if not date_string: Check cache status for a domain.
raise ValueError("Empty date string") Returns: 'not_found', 'fresh', or 'stale'
"""
if not cache_file_path.exists():
return "not_found"
try: try:
# Handle various possible formats from crt.sh with open(cache_file_path, 'r') as f:
if date_string.endswith('Z'): cache_data = json.load(f)
return datetime.fromisoformat(date_string[:-1]).replace(tzinfo=timezone.utc)
elif '+' in date_string or date_string.endswith('UTC'): last_query_str = cache_data.get("last_upstream_query")
# Handle timezone-aware strings if not last_query_str:
date_string = date_string.replace('UTC', '').strip() return "stale"
if '+' in date_string:
date_string = date_string.split('+')[0] last_query = datetime.fromisoformat(last_query_str.replace('Z', '+00:00'))
return datetime.fromisoformat(date_string).replace(tzinfo=timezone.utc) 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: else:
# Assume UTC if no timezone specified return "stale"
return datetime.fromisoformat(date_string).replace(tzinfo=timezone.utc)
except Exception as e:
# Fallback: try parsing without timezone info and assume UTC
try:
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
def _is_cert_valid(self, cert_data: Dict[str, Any]) -> bool: except (json.JSONDecodeError, ValueError, KeyError) as e:
self.logger.logger.warning(f"Invalid cache file format for {cache_file_path}: {e}")
return "stale"
def query_domain(self, domain: str) -> ProviderResult:
""" """
Check if a certificate is currently valid based on its expiry date. FIXED: Query crt.sh for certificates containing the domain.
Now properly creates domain and CA nodes instead of large entities.
Args: Args:
cert_data: Certificate data from crt.sh domain: Domain to investigate
Returns: Returns:
True if certificate is currently valid (not expired) 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)
result = ProviderResult()
try: try:
not_after_str = cert_data.get('not_after') if cache_status == "fresh":
if not not_after_str: result = self._load_from_cache(cache_file)
return False self.logger.logger.info(f"Using fresh cached crt.sh data for {domain}")
not_after_date = self._parse_certificate_date(not_after_str) else: # "stale" or "not_found"
not_before_str = cert_data.get('not_before') # Query the API for the latest certificates
new_raw_certs = self._query_crtsh_api(domain)
now = datetime.now(timezone.utc) if self._stop_event and self._stop_event.is_set():
return ProviderResult()
# Check if certificate is within valid date range # Combine with old data if cache is stale
is_not_expired = not_after_date > now if cache_status == "stale":
old_raw_certs = self._load_raw_data_from_cache(cache_file)
combined_certs = old_raw_certs + new_raw_certs
if not_before_str: # Deduplicate the combined list
not_before_date = self._parse_certificate_date(not_before_str) seen_ids = set()
is_not_before_valid = not_before_date <= now unique_certs = []
return is_not_expired and is_not_before_valid for cert in combined_certs:
cert_id = cert.get('id')
if cert_id not in seen_ids:
unique_certs.append(cert)
seen_ids.add(cert_id)
return is_not_expired raw_certificates_to_process = unique_certs
self.logger.logger.info(f"Refreshed and merged cache for {domain}. Total unique certs: {len(raw_certificates_to_process)}")
else: # "not_found"
raw_certificates_to_process = new_raw_certs
# FIXED: Process certificates to create proper domain and CA nodes
result = self._process_certificates_to_result_fixed(domain, raw_certificates_to_process)
self.logger.logger.info(f"Created fresh result for {domain} ({result.get_relationship_count()} relationships)")
# Save the new result and the raw data to the cache
self._save_result_to_cache(cache_file, result, raw_certificates_to_process, domain)
except requests.exceptions.RequestException 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:
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) as e:
self.logger.logger.error(f"Failed to load cached certificates from {cache_file_path}: {e}")
return ProviderResult()
def _load_raw_data_from_cache(self, cache_file_path: Path) -> List[Dict[str, Any]]:
"""Load only the raw certificate data from a cache file."""
try:
with open(cache_file_path, 'r') as f:
cache_content = json.load(f)
return cache_content.get("raw_certificates", [])
except (json.JSONDecodeError, FileNotFoundError):
return []
def _save_result_to_cache(self, cache_file_path: Path, result: ProviderResult, raw_certificates: List[Dict[str, Any]], domain: str) -> None:
"""Save processed crt.sh result and raw data to a cache file."""
try:
cache_data = {
"domain": domain,
"last_upstream_query": datetime.now(timezone.utc).isoformat(),
"raw_certificates": raw_certificates, # Store the raw data for deduplication
"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
]
}
cache_file_path.parent.mkdir(parents=True, exist_ok=True)
with open(cache_file_path, 'w') as f:
json.dump(cache_data, f, separators=(',', ':'), default=str)
except Exception as e: except Exception as e:
self.logger.logger.debug(f"Certificate validity check failed: {e}") self.logger.logger.warning(f"Failed to save cache file for {domain}: {e}")
return False
def _query_crtsh_api(self, domain: str) -> List[Dict[str, Any]]:
"""Query crt.sh API for raw certificate data."""
url = f"{self.base_url}?q={quote(domain)}&output=json"
response = self.make_request(url, target_indicator=domain)
if not response or response.status_code != 200:
raise requests.exceptions.RequestException(f"crt.sh API returned status {response.status_code if response else 'None'}")
try:
certificates = response.json()
except json.JSONDecodeError:
self.logger.logger.error(f"crt.sh returned invalid JSON for {domain}")
return []
if not certificates:
return []
return certificates
def _process_certificates_to_result_fixed(self, query_domain: str, certificates: List[Dict[str, Any]]) -> ProviderResult:
"""
FIXED: Process certificates to create proper domain and CA nodes.
Now creates individual domain nodes instead of large entities.
"""
result = ProviderResult()
if self._stop_event and self._stop_event.is_set():
self.logger.logger.info(f"CrtSh processing cancelled before processing for domain: {query_domain}")
return result
all_discovered_domains = set()
processed_issuers = set()
for i, cert_data in enumerate(certificates):
if i % 10 == 0 and self._stop_event and self._stop_event.is_set():
self.logger.logger.info(f"CrtSh processing cancelled at certificate {i} for domain: {query_domain}")
break
# Extract all domains from this certificate
cert_domains = self._extract_domains_from_certificate(cert_data)
all_discovered_domains.update(cert_domains)
# FIXED: Create CA nodes for certificate issuers (not as domain metadata)
issuer_name = self._parse_issuer_organization(cert_data.get('issuer_name', ''))
if issuer_name and issuer_name not in processed_issuers:
# Create relationship from query domain to CA
result.add_relationship(
source_node=query_domain,
target_node=issuer_name,
relationship_type='crtsh_cert_issuer',
provider=self.name,
confidence=0.95,
raw_data={'issuer_dn': cert_data.get('issuer_name', '')}
)
processed_issuers.add(issuer_name)
# Add certificate metadata to each domain in this certificate
cert_metadata = self._extract_certificate_metadata(cert_data)
for cert_domain in cert_domains:
if not _is_valid_domain(cert_domain):
continue
# Add certificate attributes to the domain
for key, value in cert_metadata.items():
if value is not None:
result.add_attribute(
target_node=cert_domain,
name=f"cert_{key}",
value=value,
attr_type='certificate_data',
provider=self.name,
confidence=0.9,
metadata={'certificate_id': cert_data.get('id')}
)
if self._stop_event and self._stop_event.is_set():
self.logger.logger.info(f"CrtSh query cancelled before relationship creation for domain: {query_domain}")
return result
# FIXED: Create selective relationships to avoid large entities
# Only create relationships to domains that are closely related
for discovered_domain in all_discovered_domains:
if discovered_domain == query_domain:
continue
if not _is_valid_domain(discovered_domain):
continue
# FIXED: Only create relationships for domains that share a meaningful connection
# This prevents creating too many relationships that trigger large entity creation
if self._should_create_relationship(query_domain, discovered_domain):
confidence = self._calculate_domain_relationship_confidence(
query_domain, discovered_domain, [], all_discovered_domains
)
result.add_relationship(
source_node=query_domain,
target_node=discovered_domain,
relationship_type='crtsh_san_certificate',
provider=self.name,
confidence=confidence,
raw_data={'relationship_type': 'certificate_discovery'}
)
self.log_relationship_discovery(
source_node=query_domain,
target_node=discovered_domain,
relationship_type='crtsh_san_certificate',
confidence_score=confidence,
raw_data={'relationship_type': 'certificate_discovery'},
discovery_method="certificate_transparency_analysis"
)
self.logger.logger.info(f"CrtSh processing completed for {query_domain}: {len(all_discovered_domains)} domains, {result.get_relationship_count()} relationships")
return result
def _should_create_relationship(self, source_domain: str, target_domain: str) -> bool:
"""
FIXED: Determine if a relationship should be created between two domains.
This helps avoid creating too many relationships that trigger large entity creation.
"""
# Always create relationships for subdomains
if target_domain.endswith(f'.{source_domain}') or source_domain.endswith(f'.{target_domain}'):
return True
# Create relationships for domains that share a common parent (up to 2 levels)
source_parts = source_domain.split('.')
target_parts = target_domain.split('.')
# Check if they share the same root domain (last 2 parts)
if len(source_parts) >= 2 and len(target_parts) >= 2:
source_root = '.'.join(source_parts[-2:])
target_root = '.'.join(target_parts[-2:])
return source_root == target_root
return False
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."""
Extract comprehensive metadata from certificate data. raw_issuer_name = cert_data.get('issuer_name', '')
parsed_issuer_name = self._parse_issuer_organization(raw_issuer_name)
Args:
cert_data: Raw certificate data from crt.sh
Returns:
Comprehensive certificate metadata dictionary
"""
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'),
'issuer_name': cert_data.get('issuer_name'), 'issuer_name': parsed_issuer_name,
'issuer_ca_id': cert_data.get('issuer_ca_id'), 'issuer_ca_id': cert_data.get('issuer_ca_id'),
'common_name': cert_data.get('common_name'), 'common_name': cert_data.get('common_name'),
'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': 'crtsh'
} }
try: try:
@@ -154,9 +419,9 @@ class CrtShProvider(BaseProvider):
metadata['is_currently_valid'] = self._is_cert_valid(cert_data) metadata['is_currently_valid'] = self._is_cert_valid(cert_data)
metadata['expires_soon'] = (not_after - datetime.now(timezone.utc)).days <= 30 metadata['expires_soon'] = (not_after - datetime.now(timezone.utc)).days <= 30
# Add human-readable dates # Keep raw date format or convert to standard format
metadata['not_before'] = not_before.strftime('%Y-%m-%d %H:%M:%S UTC') metadata['not_before'] = not_before.isoformat()
metadata['not_after'] = not_after.strftime('%Y-%m-%d %H:%M:%S UTC') metadata['not_after'] = not_after.isoformat()
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}")
@@ -165,328 +430,73 @@ class CrtShProvider(BaseProvider):
return metadata return metadata
def query_domain(self, domain: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]: def _parse_issuer_organization(self, issuer_dn: str) -> str:
""" """Parse the issuer Distinguished Name to extract just the organization name."""
Query crt.sh for certificates containing the domain. if not issuer_dn:
""" return issuer_dn
if not _is_valid_domain(domain):
return []
# Check for cancellation before starting
if self._stop_event and self._stop_event.is_set():
print(f"CrtSh query cancelled before start for domain: {domain}")
return []
relationships = []
try: try:
# Query crt.sh for certificates components = [comp.strip() for comp in issuer_dn.split(',')]
url = f"{self.base_url}?q={quote(domain)}&output=json"
response = self.make_request(url, target_indicator=domain, max_retries=3)
if not response or response.status_code != 200: for component in components:
return [] 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
# Check for cancellation after request return issuer_dn
if self._stop_event and self._stop_event.is_set():
print(f"CrtSh query cancelled after request for domain: {domain}")
return []
certificates = response.json() except Exception as e:
self.logger.logger.debug(f"Failed to parse issuer DN '{issuer_dn}': {e}")
return issuer_dn
if not certificates: def _parse_certificate_date(self, date_string: str) -> datetime:
return [] """Parse certificate date from crt.sh format."""
if not date_string:
raise ValueError("Empty date string")
# Check for cancellation before processing try:
if self._stop_event and self._stop_event.is_set(): if date_string.endswith('Z'):
print(f"CrtSh query cancelled before processing for domain: {domain}") return datetime.fromisoformat(date_string[:-1]).replace(tzinfo=timezone.utc)
return [] elif '+' in date_string or date_string.endswith('UTC'):
date_string = date_string.replace('UTC', '').strip()
# Aggregate certificate data by domain if '+' in date_string:
domain_certificates = {} date_string = date_string.split('+')[0]
all_discovered_domains = set() return datetime.fromisoformat(date_string).replace(tzinfo=timezone.utc)
else:
# Process certificates with cancellation checking return datetime.fromisoformat(date_string).replace(tzinfo=timezone.utc)
for i, cert_data in enumerate(certificates): except Exception as e:
# Check for cancellation every 5 certificates instead of 10 for faster response
if i % 5 == 0 and self._stop_event and self._stop_event.is_set():
print(f"CrtSh processing cancelled at certificate {i} for domain: {domain}")
break
cert_metadata = self._extract_certificate_metadata(cert_data)
cert_domains = self._extract_domains_from_certificate(cert_data)
# Add all domains from this certificate to our tracking
for cert_domain in cert_domains:
# Additional stop check during domain processing
if i % 20 == 0 and self._stop_event and self._stop_event.is_set():
print(f"CrtSh domain processing cancelled for domain: {domain}")
break
if not _is_valid_domain(cert_domain):
continue
all_discovered_domains.add(cert_domain)
# Initialize domain certificate list if needed
if cert_domain not in domain_certificates:
domain_certificates[cert_domain] = []
# Add this certificate to the domain's certificate list
domain_certificates[cert_domain].append(cert_metadata)
# Final cancellation check before creating relationships
if self._stop_event and self._stop_event.is_set():
print(f"CrtSh query cancelled before relationship creation for domain: {domain}")
return []
# Create relationships from query domain to ALL discovered domains with stop checking
for i, discovered_domain in enumerate(all_discovered_domains):
if discovered_domain == domain:
continue # Skip self-relationships
# Check for cancellation every 10 relationships
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
# Get certificates for both domains
query_domain_certs = domain_certificates.get(domain, [])
discovered_domain_certs = domain_certificates.get(discovered_domain, [])
# Find shared certificates (for metadata purposes)
shared_certificates = self._find_shared_certificates(query_domain_certs, discovered_domain_certs)
# Calculate confidence based on relationship type and shared certificates
confidence = self._calculate_domain_relationship_confidence(
domain, discovered_domain, shared_certificates, all_discovered_domains
)
# Create comprehensive raw data for the relationship
relationship_raw_data = {
'relationship_type': 'certificate_discovery',
'shared_certificates': shared_certificates,
'total_shared_certs': len(shared_certificates),
'discovery_context': self._determine_relationship_context(discovered_domain, domain),
'domain_certificates': {
domain: self._summarize_certificates(query_domain_certs),
discovered_domain: self._summarize_certificates(discovered_domain_certs)
}
}
# Create domain -> domain relationship
relationships.append((
domain,
discovered_domain,
'san_certificate',
confidence,
relationship_raw_data
))
# Log the relationship discovery
self.log_relationship_discovery(
source_node=domain,
target_node=discovered_domain,
relationship_type='san_certificate',
confidence_score=confidence,
raw_data=relationship_raw_data,
discovery_method="certificate_transparency_analysis"
)
except json.JSONDecodeError as e:
self.logger.logger.error(f"Failed to parse JSON response from crt.sh: {e}")
except requests.exceptions.RequestException as e:
self.logger.logger.error(f"HTTP request to crt.sh failed: {e}")
return relationships
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.
Args:
certs1: First domain's certificates
certs2: Second domain's certificates
Returns:
List of shared certificate metadata
"""
shared = []
# Create a set of certificate IDs from the first list for quick lookup
cert1_ids = {cert.get('certificate_id') for cert in certs1 if cert.get('certificate_id')}
# Find certificates in the second list that match
for cert in certs2:
if cert.get('certificate_id') in cert1_ids:
shared.append(cert)
return shared
def _summarize_certificates(self, certificates: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Create a summary of certificates for a domain.
Args:
certificates: List of certificate metadata
Returns:
Summary dictionary with aggregate statistics
"""
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
}
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'))
# Get unique issuers
unique_issuers = list(set(cert.get('issuer_name') for cert in certificates if cert.get('issuer_name')))
# Find the most recent certificate
latest_cert = None
latest_date = None
for cert in certificates:
try: try:
if cert.get('not_before'): return datetime.strptime(date_string[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc)
cert_date = self._parse_certificate_date(cert['not_before'])
if latest_date is None or cert_date > latest_date:
latest_date = cert_date
latest_cert = cert
except Exception: except Exception:
continue raise ValueError(f"Unable to parse date: {date_string}") from e
return { def _is_cert_valid(self, cert_data: Dict[str, Any]) -> bool:
'total_certificates': len(certificates), """Check if a certificate is currently valid based on its expiry date."""
'valid_certificates': valid_count, try:
'expired_certificates': expired_count, not_after_str = cert_data.get('not_after')
'expires_soon_count': expires_soon_count, if not not_after_str:
'unique_issuers': unique_issuers, return False
'latest_certificate': latest_cert,
'has_valid_cert': valid_count > 0,
'certificate_details': certificates # Full details for forensic analysis
}
def _calculate_domain_relationship_confidence(self, domain1: str, domain2: str, not_after_date = self._parse_certificate_date(not_after_str)
shared_certificates: List[Dict[str, Any]], not_before_str = cert_data.get('not_before')
all_discovered_domains: Set[str]) -> float:
"""
Calculate confidence score for domain relationship based on various factors.
Args: now = datetime.now(timezone.utc)
domain1: Source domain (query domain) is_not_expired = not_after_date > now
domain2: Target domain (discovered domain)
shared_certificates: List of shared certificate metadata
all_discovered_domains: All domains discovered in this query
Returns: if not_before_str:
Confidence score between 0.0 and 1.0 not_before_date = self._parse_certificate_date(not_before_str)
""" is_not_before_valid = not_before_date <= now
base_confidence = 0.9 return is_not_expired and is_not_before_valid
# Adjust confidence based on domain relationship context return is_not_expired
relationship_context = self._determine_relationship_context(domain2, domain1)
if relationship_context == 'exact_match': except Exception as e:
context_bonus = 0.0 # This shouldn't happen, but just in case return False
elif relationship_context == 'subdomain':
context_bonus = 0.1 # High confidence for subdomains
elif relationship_context == 'parent_domain':
context_bonus = 0.05 # Medium confidence for parent domains
else:
context_bonus = 0.0 # Related domains get base confidence
# 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
# Additional bonus for valid shared certificates
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:
# Even without shared certificates, domains found in the same query have some relationship
shared_bonus = 0.0
validity_bonus = 0.0
# Adjust confidence based on certificate issuer reputation (if shared certificates exist)
issuer_bonus = 0.0
if shared_certificates:
for cert in shared_certificates:
issuer = cert.get('issuer_name', '').lower()
if any(trusted_ca in issuer for trusted_ca in ['let\'s encrypt', 'digicert', 'sectigo', 'globalsign']):
issuer_bonus = max(issuer_bonus, 0.03)
break
# Calculate final confidence
final_confidence = base_confidence + context_bonus + shared_bonus + validity_bonus + issuer_bonus
return max(0.1, min(1.0, final_confidence)) # Clamp between 0.1 and 1.0
def _determine_relationship_context(self, cert_domain: str, query_domain: str) -> str:
"""
Determine the context of the relationship between certificate domain and query domain.
Args:
cert_domain: Domain found in certificate
query_domain: Original query domain
Returns:
String describing the relationship context
"""
if cert_domain == query_domain:
return 'exact_match'
elif cert_domain.endswith(f'.{query_domain}'):
return 'subdomain'
elif query_domain.endswith(f'.{cert_domain}'):
return 'parent_domain'
else:
return 'related_domain'
def query_ip(self, ip: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]:
"""
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 list (crt.sh doesn't support IP-based certificate queries effectively)
"""
# crt.sh doesn't effectively support IP-based certificate queries
return []
def _extract_domains_from_certificate(self, cert_data: Dict[str, Any]) -> Set[str]: def _extract_domains_from_certificate(self, cert_data: Dict[str, Any]) -> Set[str]:
""" """Extract all domains from certificate data."""
Extract all domains from certificate data.
Args:
cert_data: Certificate data from crt.sh API
Returns:
Set of unique domain names found in the certificate
"""
domains = set() domains = set()
# Extract from common name # Extract from common name
@@ -499,7 +509,6 @@ class CrtShProvider(BaseProvider):
# Extract from name_value field (contains SANs) # Extract from name_value field (contains SANs)
name_value = cert_data.get('name_value', '') name_value = cert_data.get('name_value', '')
if name_value: if name_value:
# Split by newlines and clean each domain
for line in name_value.split('\n'): for line in name_value.split('\n'):
cleaned_domains = self._clean_domain_name(line.strip()) cleaned_domains = self._clean_domain_name(line.strip())
if cleaned_domains: if cleaned_domains:
@@ -508,37 +517,28 @@ class CrtShProvider(BaseProvider):
return domains return domains
def _clean_domain_name(self, domain_name: str) -> List[str]: def _clean_domain_name(self, domain_name: str) -> List[str]:
""" """Clean and normalize domain name from certificate data."""
Clean and normalize domain name from certificate data.
Now returns a list to handle wildcards correctly.
"""
if not domain_name: if not domain_name:
return [] return []
domain = domain_name.strip().lower() domain = domain_name.strip().lower()
# Remove protocol if present
if domain.startswith(('http://', 'https://')): if domain.startswith(('http://', 'https://')):
domain = domain.split('://', 1)[1] domain = domain.split('://', 1)[1]
# Remove path if present
if '/' in domain: if '/' in domain:
domain = domain.split('/', 1)[0] domain = domain.split('/', 1)[0]
# Remove port if present if ':' in domain and not domain.count(':') > 1:
if ':' in domain and not domain.count(':') > 1: # Avoid breaking IPv6
domain = domain.split(':', 1)[0] domain = domain.split(':', 1)[0]
# Handle wildcard domains
cleaned_domains = [] cleaned_domains = []
if domain.startswith('*.'): if domain.startswith('*.'):
# Add both the wildcard and the base domain
cleaned_domains.append(domain) cleaned_domains.append(domain)
cleaned_domains.append(domain[2:]) cleaned_domains.append(domain[2:])
else: else:
cleaned_domains.append(domain) cleaned_domains.append(domain)
# Remove any remaining invalid characters and validate
final_domains = [] final_domains = []
for d in cleaned_domains: for d in cleaned_domains:
d = re.sub(r'[^\w\-\.]', '', d) d = re.sub(r'[^\w\-\.]', '', d)
@@ -546,3 +546,35 @@ class CrtShProvider(BaseProvider):
final_domains.append(d) final_domains.append(d)
return [d for d in final_domains if _is_valid_domain(d)] return [d for d in final_domains if _is_valid_domain(d)]
def _calculate_domain_relationship_confidence(self, domain1: str, domain2: str,
shared_certificates: List[Dict[str, Any]],
all_discovered_domains: Set[str]) -> float:
"""Calculate confidence score for domain relationship based on various factors."""
base_confidence = 0.9
# Adjust confidence based on domain relationship context
relationship_context = self._determine_relationship_context(domain2, domain1)
if relationship_context == 'exact_match':
context_bonus = 0.0
elif relationship_context == 'subdomain':
context_bonus = 0.1
elif relationship_context == 'parent_domain':
context_bonus = 0.05
else:
context_bonus = 0.0
final_confidence = base_confidence + context_bonus
return max(0.1, min(1.0, final_confidence))
def _determine_relationship_context(self, cert_domain: str, query_domain: str) -> str:
"""Determine the context of the relationship between certificate domain and query domain."""
if cert_domain == query_domain:
return 'exact_match'
elif cert_domain.endswith(f'.{query_domain}'):
return 'subdomain'
elif query_domain.endswith(f'.{cert_domain}'):
return 'parent_domain'
else:
return 'related_domain'

View File

@@ -1,19 +1,19 @@
# dnsrecon/providers/dns_provider.py # dnsrecon/providers/dns_provider.py
import dns.resolver from dns import resolver, reversename
import dns.reversename from typing import Dict
from typing import List, Dict, Any, Tuple
from .base_provider import BaseProvider from .base_provider import BaseProvider
from utils.helpers import _is_valid_ip, _is_valid_domain from core.provider_result import ProviderResult
from utils.helpers import _is_valid_ip, _is_valid_domain, get_ip_version
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 uses session-specific configuration. Now returns standardized ProviderResult objects with IPv4 and IPv6 support.
""" """
def __init__(self, session_config=None): def __init__(self, name=None, session_config=None):
"""Initialize DNS provider with session-specific configuration.""" """Initialize DNS provider with session-specific configuration."""
super().__init__( super().__init__(
name="dns", name="dns",
@@ -23,10 +23,9 @@ class DNSProvider(BaseProvider):
) )
# Configure DNS resolver # Configure DNS resolver
self.resolver = dns.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."""
@@ -48,97 +47,149 @@ 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) -> List[Tuple[str, str, str, float, Dict[str, Any]]]: def query_domain(self, domain: str) -> ProviderResult:
""" """
Query DNS records for the domain to discover relationships. Query DNS records for the domain to discover relationships and attributes.
FIXED: Now creates separate attributes for each DNS record type.
Args: Args:
domain: Domain to investigate domain: Domain to investigate
Returns: Returns:
List of relationships discovered from DNS analysis ProviderResult containing discovered relationships and attributes
""" """
if not _is_valid_domain(domain): if not _is_valid_domain(domain):
return [] return ProviderResult()
relationships = [] result = ProviderResult()
# Query all record types # Query all record types - each gets its own attribute
for record_type in ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'SOA', 'TXT', 'SRV', 'CAA']: for record_type in ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'SOA', 'TXT', 'SRV', 'CAA']:
relationships.extend(self._query_record(domain, record_type)) try:
self._query_record(domain, record_type, result)
#except resolver.NoAnswer:
# 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}")
except Exception as e:
self.failed_requests += 1
self.logger.logger.debug(f"{record_type} record query failed for {domain}: {e}")
return relationships return result
def query_ip(self, ip: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]: def query_ip(self, ip: str) -> ProviderResult:
""" """
Query reverse DNS for the IP address. Query reverse DNS for the IP address (supports both IPv4 and IPv6).
Args: Args:
ip: IP address to investigate ip: IP address to investigate (IPv4 or IPv6)
Returns: Returns:
List of relationships discovered from reverse DNS ProviderResult containing discovered relationships and attributes
""" """
if not _is_valid_ip(ip): if not _is_valid_ip(ip):
return [] return ProviderResult()
relationships = [] result = ProviderResult()
ip_version = get_ip_version(ip)
try: try:
# Perform reverse DNS lookup # Perform reverse DNS lookup (works for both IPv4 and IPv6)
self.total_requests += 1 self.total_requests += 1
reverse_name = dns.reversename.from_address(ip) reverse_name = reversename.from_address(ip)
response = self.resolver.resolve(reverse_name, 'PTR') response = self.resolver.resolve(reverse_name, 'PTR')
self.successful_requests += 1 self.successful_requests += 1
ptr_records = []
for ptr_record in response: for ptr_record in response:
hostname = str(ptr_record).rstrip('.') hostname = str(ptr_record).rstrip('.')
if _is_valid_domain(hostname): if _is_valid_domain(hostname):
raw_data = { # Determine appropriate forward relationship type based on IP version
'query_type': 'PTR', if ip_version == 6:
'ip_address': ip, relationship_type = 'dns_aaaa_record'
'hostname': hostname, record_prefix = 'AAAA'
'ttl': response.ttl else:
} relationship_type = 'dns_a_record'
record_prefix = 'A'
relationships.append(( # Add the relationship
ip, result.add_relationship(
hostname, source_node=ip,
'ptr_record', target_node=hostname,
0.8, relationship_type='dns_ptr_record',
raw_data provider=self.name,
)) confidence=0.8,
raw_data={
'query_type': 'PTR',
'ip_address': ip,
'ip_version': ip_version,
'hostname': hostname,
'ttl': response.ttl
}
)
# Add to PTR records list
ptr_records.append(f"PTR: {hostname}")
# 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='dns_ptr_record',
confidence_score=0.8, confidence_score=0.8,
raw_data=raw_data, raw_data={
discovery_method="reverse_dns_lookup" 'query_type': 'PTR',
'ip_address': ip,
'ip_version': ip_version,
'hostname': hostname,
'ttl': response.ttl
},
discovery_method=f"reverse_dns_lookup_ipv{ip_version}"
) )
# Add PTR records as separate attribute
if ptr_records:
result.add_attribute(
target_node=ip,
name='ptr_records', # Specific name for PTR records
value=ptr_records,
attr_type='dns_record',
provider=self.name,
confidence=0.8,
metadata={'ttl': response.ttl, 'ip_version': ip_version}
)
except resolver.NXDOMAIN:
self.failed_requests += 1
self.logger.logger.debug(f"Reverse DNS lookup failed for {ip}: NXDOMAIN")
except Exception as e: except Exception as e:
self.failed_requests += 1 self.failed_requests += 1
self.logger.logger.debug(f"Reverse DNS lookup failed for {ip}: {e}") self.logger.logger.debug(f"Reverse DNS lookup failed for {ip}: {e}")
# Re-raise the exception so the scanner can handle the failure
raise e
return relationships return result
def _query_record(self, domain: str, record_type: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]: def _query_record(self, domain: str, record_type: str, result: ProviderResult) -> None:
""" """
Query a specific type of DNS record for the domain. FIXED: Query DNS records with unique attribute names for each record type.
Enhanced to better handle IPv6 AAAA records.
""" """
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']:
target = str(record) target = str(record)
# Validate that the IP address is properly formed
if not _is_valid_ip(target):
self.logger.logger.debug(f"Invalid IP address in {record_type} record: {target}")
continue
elif record_type in ['CNAME', 'NS', 'PTR']: elif record_type in ['CNAME', 'NS', 'PTR']:
target = str(record.target).rstrip('.') target = str(record.target).rstrip('.')
elif record_type == 'MX': elif record_type == 'MX':
@@ -146,32 +197,56 @@ 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 metadata, not relationships. # Keep raw TXT record value
txt_value = str(record).strip('"')
dns_records.append(txt_value) # Just the value for TXT
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':
target = f"{record.flags} {record.tag.decode('utf-8')} \"{record.value.decode('utf-8')}\"" # Keep raw CAA record format
caa_value = f"{record.flags} {record.tag.decode('utf-8')} \"{record.value.decode('utf-8')}\""
dns_records.append(caa_value) # Just the value for CAA
continue
else: else:
target = str(record) target = str(record)
if target: if target:
# Determine IP version for metadata if this is an IP record
ip_version = None
if record_type in ['A', 'AAAA'] and _is_valid_ip(target):
ip_version = get_ip_version(target)
raw_data = { raw_data = {
'query_type': record_type, 'query_type': record_type,
'domain': domain, 'domain': domain,
'value': target, 'value': target,
'ttl': response.ttl 'ttl': response.ttl
} }
relationship_type = f"{record_type.lower()}_record"
confidence = 0.8 # Default confidence for DNS records
relationships.append(( if ip_version:
domain, raw_data['ip_version'] = ip_version
target,
relationship_type, relationship_type = f"dns_{record_type.lower()}_record"
confidence, confidence = 0.8
raw_data
)) # Add relationship
result.add_relationship(
source_node=domain,
target_node=target,
relationship_type=relationship_type,
provider=self.name,
confidence=confidence,
raw_data=raw_data
)
# Add target to records list
dns_records.append(target)
# Log relationship discovery with IP version info
discovery_method = f"dns_{record_type.lower()}_record"
if ip_version:
discovery_method += f"_ipv{ip_version}"
self.log_relationship_discovery( self.log_relationship_discovery(
source_node=domain, source_node=domain,
@@ -179,11 +254,33 @@ class DNSProvider(BaseProvider):
relationship_type=relationship_type, relationship_type=relationship_type,
confidence_score=confidence, confidence_score=confidence,
raw_data=raw_data, raw_data=raw_data,
discovery_method=f"dns_{record_type.lower()}_record" discovery_method=discovery_method
) )
# FIXED: Create attribute with specific name for each record type
if dns_records:
# Use record type specific attribute name (e.g., 'a_records', 'mx_records', etc.)
attribute_name = f"{record_type.lower()}_records"
metadata = {'record_type': record_type, 'ttl': response.ttl}
# Add IP version info for A/AAAA records
if record_type in ['A', 'AAAA'] and dns_records:
first_ip_version = get_ip_version(dns_records[0])
if first_ip_version:
metadata['ip_version'] = first_ip_version
result.add_attribute(
target_node=domain,
name=attribute_name, # UNIQUE name for each record type!
value=dns_records,
attr_type='dns_record_list',
provider=self.name,
confidence=0.8,
metadata=metadata
)
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}")
raise e
return relationships

View File

@@ -1,21 +1,23 @@
""" # dnsrecon/providers/shodan_provider.py
Shodan provider for DNSRecon.
Discovers IP relationships and infrastructure context through Shodan API.
"""
import json import json
from typing import List, Dict, Any, Tuple from pathlib import Path
from typing import Dict, Any
from datetime import datetime, timezone
import requests
from .base_provider import BaseProvider from .base_provider import BaseProvider
from utils.helpers import _is_valid_ip, _is_valid_domain from core.provider_result import ProviderResult
from utils.helpers import _is_valid_ip, _is_valid_domain, get_ip_version, normalize_ip
class ShodanProvider(BaseProvider): class ShodanProvider(BaseProvider):
""" """
Provider for querying Shodan API for IP address and hostname information. Provider for querying Shodan API for IP address information.
Now uses session-specific API keys. Now returns standardized ProviderResult objects with caching support for IPv4 and IPv6.
""" """
def __init__(self, session_config=None): def __init__(self, name=None, session_config=None):
"""Initialize Shodan provider with session-specific configuration.""" """Initialize Shodan provider with session-specific configuration."""
super().__init__( super().__init__(
name="shodan", name="shodan",
@@ -26,6 +28,10 @@ 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)."""
return self.api_key is not None and len(self.api_key.strip()) > 0 return self.api_key is not None and len(self.api_key.strip()) > 0
@@ -36,7 +42,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."""
@@ -44,267 +50,300 @@ 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': True, 'ips': True} return {'domains': False, 'ips': True}
def query_domain(self, domain: str) -> List[Tuple[str, str, str, float, Dict[str, Any]]]: def _get_cache_file_path(self, ip: str) -> Path:
""" """
Query Shodan for information about a domain. Generate cache file path for an IP address (IPv4 or IPv6).
Uses Shodan's hostname search to find associated IPs. IPv6 addresses contain colons which are replaced with underscores for filesystem safety.
"""
# Normalize the IP address first to ensure consistent caching
normalized_ip = normalize_ip(ip)
if not normalized_ip:
# Fallback for invalid IPs
safe_ip = ip.replace('.', '_').replace(':', '_')
else:
# Replace problematic characters for both IPv4 and IPv6
safe_ip = normalized_ip.replace('.', '_').replace(':', '_')
return self.cache_dir / f"{safe_ip}.json"
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: Returns:
List of relationships discovered from Shodan data Empty ProviderResult
""" """
if not _is_valid_domain(domain) or not self.is_available(): return ProviderResult()
return []
relationships = [] def query_ip(self, ip: str) -> ProviderResult:
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. Query Shodan for information about an IP address (IPv4 or IPv6), with caching of processed data.
Args: Args:
ip: IP address to investigate ip: IP address to investigate (IPv4 or IPv6)
Returns: Returns:
List of relationships discovered from Shodan IP data ProviderResult containing discovered relationships and attributes
""" """
if not _is_valid_ip(ip) or not self.is_available(): if not _is_valid_ip(ip) or not self.is_available():
return [] return ProviderResult()
relationships = [] # Normalize IP address for consistent processing
normalized_ip = normalize_ip(ip)
if not normalized_ip:
return ProviderResult()
cache_file = self._get_cache_file_path(normalized_ip)
cache_status = self._get_cache_status(cache_file)
result = ProviderResult()
try: try:
# Query Shodan host information if cache_status == "fresh":
url = f"{self.base_url}/shodan/host/{ip}" result = self._load_from_cache(cache_file)
params = {'key': self.api_key} self.logger.logger.info(f"Using cached Shodan data for {normalized_ip}")
else: # "stale" or "not_found"
url = f"{self.base_url}/shodan/host/{normalized_ip}"
params = {'key': self.api_key}
response = self.make_request(url, method="GET", params=params, target_indicator=normalized_ip)
response = self.make_request(url, method="GET", params=params, target_indicator=ip) if response and response.status_code == 200:
data = response.json()
if not response or response.status_code != 200: # Process the data into ProviderResult BEFORE caching
return [] result = self._process_shodan_data(normalized_ip, data)
self._save_to_cache(cache_file, result, data) # Save both result and raw data
data = response.json() elif response and response.status_code == 404:
# Handle 404 "No information available" as successful empty result
# Extract hostname relationships try:
hostnames = data.get('hostnames', []) error_data = response.json()
for hostname in hostnames: if "No information available" in error_data.get('error', ''):
if _is_valid_domain(hostname): # This is a successful query - Shodan just has no data
raw_data = { self.logger.logger.debug(f"Shodan has no information for {normalized_ip}")
'ip_address': ip, result = ProviderResult() # Empty but successful result
'hostname': hostname, # Cache the empty result to avoid repeated queries
'country': data.get('country_name', ''), self._save_to_cache(cache_file, result, {'error': 'No information available'})
'city': data.get('city', ''), else:
'isp': data.get('isp', ''), # Some other 404 error - treat as failure
'org': data.get('org', ''), raise requests.exceptions.RequestException(f"Shodan API returned 404: {error_data}")
'asn': data.get('asn', ''), except (ValueError, KeyError):
'ports': data.get('ports', []), # Could not parse JSON response - treat as failure
'last_update': data.get('last_update', ''), raise requests.exceptions.RequestException(f"Shodan API returned 404 with unparseable response")
'os': data.get('os', '') elif cache_status == "stale":
} # If API fails on a stale cache, use the old data
result = self._load_from_cache(cache_file)
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: else:
asn_name = f"AS{asn}" # Other HTTP error codes should be treated as failures
asn_number = str(asn) status_code = response.status_code if response else "No response"
raise requests.exceptions.RequestException(f"Shodan API returned HTTP {status_code}")
asn_raw_data = { except requests.exceptions.RequestException as e:
'ip_address': ip, self.logger.logger.info(f"Shodan API query returned no info for {normalized_ip}: {e}")
'asn': asn_number, if cache_status == "stale":
'isp': data.get('isp', ''), result = self._load_from_cache(cache_file)
'org': data.get('org', '') else:
} # Re-raise for retry scheduling - but only for actual failures
raise e
relationships.append(( return result
ip,
asn_name,
'asn_membership',
0.7,
asn_raw_data
))
self.log_relationship_discovery( def _load_from_cache(self, cache_file_path: Path) -> ProviderResult:
source_node=ip, """Load processed Shodan data from a cache file."""
target_node=asn_name, try:
relationship_type='asn_membership', with open(cache_file_path, 'r') as f:
confidence_score=0.7, cache_content = json.load(f)
raw_data=asn_raw_data,
discovery_method="shodan_asn_lookup" 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", {})
) )
except json.JSONDecodeError as e: # Reconstruct attributes
self.logger.logger.error(f"Failed to parse JSON response from Shodan: {e}") 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 relationships return result
def search_by_organization(self, org_name: str) -> List[Dict[str, Any]]: except (json.JSONDecodeError, FileNotFoundError, KeyError):
""" return ProviderResult()
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 []
def _save_to_cache(self, cache_file_path: Path, result: ProviderResult, raw_data: Dict[str, Any]) -> None:
"""Save processed Shodan data to a cache file."""
try: try:
search_query = f"org:\"{org_name}\"" cache_data = {
url = f"{self.base_url}/shodan/host/search" "last_upstream_query": datetime.now(timezone.utc).isoformat(),
params = { "raw_data": raw_data, # Preserve original for forensic purposes
'key': self.api_key, "relationships": [
'query': search_query, {
'minify': True "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:
response = self.make_request(url, method="GET", params=params, target_indicator=org_name) json.dump(cache_data, f, separators=(',', ':'), default=str)
if response and response.status_code == 200:
data = response.json()
return data.get('matches', [])
except Exception as e: except Exception as e:
self.logger.logger.error(f"Error searching Shodan by organization {org_name}: {e}") self.logger.logger.warning(f"Failed to save Shodan cache for {cache_file_path.name}: {e}")
return [] def _process_shodan_data(self, ip: str, data: Dict[str, Any]) -> ProviderResult:
def get_host_services(self, ip: str) -> List[Dict[str, Any]]:
""" """
Get service information for a specific IP address. VERIFIED: Process Shodan data creating ISP nodes with ASN attributes and proper relationships.
Enhanced to include IP version information for IPv6 addresses.
Args:
ip: IP address to query
Returns:
List of service information dictionaries
""" """
if not _is_valid_ip(ip) or not self.is_available(): result = ProviderResult()
return []
try: # Determine IP version for metadata
url = f"{self.base_url}/shodan/host/{ip}" ip_version = get_ip_version(ip)
params = {'key': self.api_key}
response = self.make_request(url, method="GET", params=params, target_indicator=ip) # VERIFIED: Extract ISP information and create proper ISP node with ASN
isp_name = data.get('org')
asn_value = data.get('asn')
if response and response.status_code == 200: if isp_name and asn_value:
data = response.json() # Create relationship from IP to ISP
return data.get('data', []) # Service banners result.add_relationship(
source_node=ip,
target_node=isp_name,
relationship_type='shodan_isp',
provider=self.name,
confidence=0.9,
raw_data={'asn': asn_value, 'shodan_org': isp_name, 'ip_version': ip_version}
)
except Exception as e: # Add ASN as attribute to the ISP node
self.logger.logger.error(f"Error getting Shodan services for IP {ip}: {e}") result.add_attribute(
target_node=isp_name,
name='asn',
value=asn_value,
attr_type='isp_info',
provider=self.name,
confidence=0.9,
metadata={'description': 'Autonomous System Number from Shodan', 'ip_version': ip_version}
)
return [] # Also add organization name as attribute to ISP node for completeness
result.add_attribute(
target_node=isp_name,
name='organization_name',
value=isp_name,
attr_type='isp_info',
provider=self.name,
confidence=0.9,
metadata={'description': 'Organization name from Shodan', 'ip_version': ip_version}
)
# Process hostnames (reverse DNS)
for key, value in data.items():
if key == 'hostnames':
for hostname in value:
if _is_valid_domain(hostname):
# Use appropriate relationship type based on IP version
if ip_version == 6:
relationship_type = 'shodan_aaaa_record'
else:
relationship_type = 'shodan_a_record'
result.add_relationship(
source_node=ip,
target_node=hostname,
relationship_type=relationship_type,
provider=self.name,
confidence=0.8,
raw_data={**data, 'ip_version': ip_version}
)
self.log_relationship_discovery(
source_node=ip,
target_node=hostname,
relationship_type=relationship_type,
confidence_score=0.8,
raw_data={**data, 'ip_version': ip_version},
discovery_method=f"shodan_host_lookup_ipv{ip_version}"
)
elif key == 'ports':
# Add open ports as attributes to the IP
for port in value:
result.add_attribute(
target_node=ip,
name='shodan_open_port',
value=port,
attr_type='shodan_network_info',
provider=self.name,
confidence=0.9,
metadata={'ip_version': ip_version}
)
elif isinstance(value, (str, int, float, bool)) and value is not None:
# Add other Shodan fields as IP attributes (keep raw field names)
result.add_attribute(
target_node=ip,
name=key, # Raw field name from Shodan API
value=value,
attr_type='shodan_info',
provider=self.name,
confidence=0.9,
metadata={'ip_version': ip_version}
)
return result

View File

@@ -1,9 +1,10 @@
Flask>=2.3.3 Flask
networkx>=3.1 networkx
requests>=2.31.0 requests
python-dateutil>=2.8.2 python-dateutil
Werkzeug>=2.3.7 Werkzeug
urllib3>=2.0.0 urllib3
dnspython>=2.4.2 dnspython
gunicorn gunicorn
redis redis
python-dotenv

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -7,8 +8,11 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/main.css') }}">
<script src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.css" rel="stylesheet" type="text/css"> <link href="https://cdnjs.cloudflare.com/ajax/libs/vis/4.21.0/vis.min.css" rel="stylesheet" type="text/css">
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500;700&family=Special+Elite&display=swap" rel="stylesheet"> <link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@300;400;500;700&family=Special+Elite&display=swap"
rel="stylesheet">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header class="header"> <header class="header">
@@ -32,19 +36,8 @@
<div class="form-container"> <div class="form-container">
<div class="input-group"> <div class="input-group">
<label for="target-domain">Target Domain</label> <label for="target-input">Target Domain or IP</label>
<input type="text" id="target-domain" placeholder="example.com" autocomplete="off"> <input type="text" id="target-input" placeholder="example.com or 8.8.8.8" 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">
@@ -60,13 +53,13 @@
<span class="btn-icon">[STOP]</span> <span class="btn-icon">[STOP]</span>
<span>Terminate Scan</span> <span>Terminate Scan</span>
</button> </button>
<button id="export-results" class="btn btn-secondary"> <button id="export-options" class="btn btn-secondary">
<span class="btn-icon">[EXPORT]</span> <span class="btn-icon">[EXPORT]</span>
<span>Download Results</span> <span>Export Options</span>
</button> </button>
<button id="configure-api-keys" class="btn btn-secondary"> <button id="configure-settings" class="btn btn-secondary">
<span class="btn-icon">[API]</span> <span class="btn-icon">[API]</span>
<span>Configure API Keys</span> <span>Settings</span>
</button> </button>
</div> </div>
</div> </div>
@@ -90,54 +83,47 @@
<span class="status-label">Depth:</span> <span class="status-label">Depth:</span>
<span id="depth-display" class="status-value">0/0</span> <span id="depth-display" class="status-value">0/0</span>
</div> </div>
<div class="status-row">
<span class="status-label">Progress:</span>
<span id="progress-display" class="status-value">0%</span>
</div>
<div class="status-row">
<span class="status-label">Indicators:</span>
<span id="indicators-display" class="status-value">0</span>
</div>
<div class="status-row"> <div class="status-row">
<span class="status-label">Relationships:</span> <span class="status-label">Relationships:</span>
<span id="relationships-display" class="status-value">0</span> <span id="relationships-display" class="status-value">0</span>
</div> </div>
</div> </div>
<div class="progress-bar"> <div class="progress-container">
<div id="progress-fill" class="progress-fill"></div> <div class="progress-info">
<span id="progress-label">Progress:</span>
<span id="progress-compact">0/0</span>
</div>
<div class="progress-bar">
<div id="progress-fill" class="progress-fill"></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">
<div class="graph-placeholder"> <div class="graph-placeholder">
<div class="placeholder-content"> <div class="placeholder-content">
<div class="placeholder-icon">[]</div> <div class="placeholder-icon">[]</div>
<div class="placeholder-text">Infrastructure map will appear here</div> <div class="placeholder-text">Infrastructure map will appear here</div>
<div class="placeholder-subtext">Start a reconnaissance scan to visualize relationships</div> <div class="placeholder-subtext">Start a reconnaissance scan to visualize relationships
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -147,29 +133,30 @@
<div class="legend-color" style="background-color: #00ff41;"></div> <div class="legend-color" style="background-color: #00ff41;"></div>
<span>Domains</span> <span>Domains</span>
</div> </div>
<div class="legend-item">
<div class="legend-color" style="background-color: #c92f2f;"></div>
<span>Domain (no valid cert)</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #c7c7c7;"></div>
<span>Domain (never had cert)</span>
</div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color" style="background-color: #ff9900;"></div> <div class="legend-color" style="background-color: #ff9900;"></div>
<span>IP Addresses</span> <span>IP Addresses</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color" style="background-color: #c7c7c7;"></div> <div class="legend-color" style="background-color: #00aaff;"></div>
<span>Domain (invalid cert)</span> <span>ISPs</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #9d4edd;"></div>
<span>Correlation Objects</span>
</div>
<div class="legend-item">
<div class="legend-edge high-confidence"></div>
<span>High Confidence</span>
</div>
<div class="legend-item">
<div class="legend-edge medium-confidence"></div>
<span>Medium Confidence</span>
</div> </div>
<div class="legend-item"> <div class="legend-item">
<div class="legend-color" style="background-color: #ff6b6b;"></div> <div class="legend-color" style="background-color: #ff6b6b;"></div>
<span>Large Entity</span> <span>Certificate Authorities</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background-color: #9d4edd;"></div>
<span>Correlation Objects</span>
</div> </div>
</div> </div>
</section> </section>
@@ -180,7 +167,7 @@
</div> </div>
<div id="provider-list" class="provider-list"> <div id="provider-list" class="provider-list">
</div> </div>
</section> </section>
</main> </main>
@@ -202,49 +189,127 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div id="modal-details"> <div id="modal-details">
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div id="api-key-modal" class="modal"> <!-- Settings Modal -->
<div id="settings-modal" class="modal">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3>Configure API Keys</h3> <h3>Scanner Configuration</h3>
<button id="api-key-modal-close" class="modal-close">[×]</button> <button id="settings-modal-close" class="modal-close">[×]</button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p class="modal-description"> <div class="modal-details">
Enter your API keys for enhanced data providers. Keys are stored in memory for the current session only and are never saved to disk. <!-- Scan Settings Section -->
</p> <section class="modal-section">
<div id="api-key-inputs"> <details open>
<summary>
<span>⚙️ Scan Settings</span>
</summary>
<div class="modal-section-content">
<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>
</details>
</section>
<!-- Provider Configuration Section -->
<section class="modal-section">
<details open>
<summary>
<span>🔧 Provider Configuration</span>
<span class="merge-badge" id="provider-count">0</span>
</summary>
<div class="modal-section-content">
<div id="provider-config-list">
<!-- Dynamically populated -->
</div>
</div>
</details>
</section>
<!-- API Keys Section -->
<section class="modal-section">
<details>
<summary>
<span>🔑 API Keys</span>
<span class="merge-badge" id="api-key-count">0</span>
</summary>
<div class="modal-section-content">
<p class="placeholder-subtext" style="margin-bottom: 1rem;">
⚠️ API keys are stored in memory for the current session only.
Only provide API keys you don't use for anything else.
</p>
<div id="api-key-inputs">
<!-- Dynamically populated -->
</div>
</div>
</details>
</section>
<!-- Action Buttons -->
<div class="button-group" style="margin-top: 1.5rem;">
<button id="save-settings" class="btn btn-primary">
<span class="btn-icon">[SAVE]</span>
<span>Save Configuration</span>
</button>
<button id="reset-settings" class="btn btn-secondary">
<span class="btn-icon">[RESET]</span>
<span>Reset to Defaults</span>
</button>
</div> </div>
<div class="button-group" style="flex-direction: row; justify-content: flex-end;"> </div>
<button id="reset-api-keys" class="btn btn-secondary"> </div>
<span>Reset</span> </div>
</button> </div>
<button id="save-api-keys" class="btn btn-primary">
<span>Save Keys</span> <!-- Export Modal -->
</button> <div id="export-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Export Options</h3>
<button id="export-modal-close" class="modal-close">[×]</button>
</div>
<div class="modal-body">
<div class="modal-details">
<section class="modal-section">
<details open>
<summary>
<span>📊 Available Exports</span>
</summary>
<div class="modal-section-content">
<div class="button-group" style="margin-top: 1rem;">
<button id="export-graph-json" class="btn btn-primary">
<span class="btn-icon">[JSON]</span>
<span>Export Graph Data</span>
</button>
<div class="status-row" style="margin-top: 0.5rem;">
<span class="status-label">Complete graph data with forensic audit trail,
provider statistics, and scan metadata in JSON format for analysis and
archival.</span>
</div>
</div>
</div>
</details>
</section>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<script>
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
const textToCopy = element.innerText;
navigator.clipboard.writeText(textToCopy).then(() => {
// Optional: Show a success message
console.log('Copied to clipboard');
}).catch(err => {
console.error('Failed to copy: ', err);
});
}
</script>
<script src="{{ url_for('static', filename='js/graph.js') }}"></script> <script src="{{ url_for('static', filename='js/graph.js') }}"></script>
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
</body> </body>
</html> </html>

View File

@@ -1,3 +1,8 @@
# dnsrecon-reduced/utils/helpers.py
import ipaddress
from typing import Union
def _is_valid_domain(domain: str) -> bool: def _is_valid_domain(domain: str) -> bool:
""" """
Basic domain validation. Basic domain validation.
@@ -26,25 +31,64 @@ def _is_valid_domain(domain: str) -> bool:
def _is_valid_ip(ip: str) -> bool: def _is_valid_ip(ip: str) -> bool:
""" """
Basic IP address validation. IP address validation supporting both IPv4 and IPv6.
Args: Args:
ip: IP address string to validate ip: IP address string to validate
Returns: Returns:
True if IP appears valid True if IP appears valid (IPv4 or IPv6)
""" """
if not ip:
return False
try: try:
parts = ip.split('.') # This handles both IPv4 and IPv6 validation
if len(parts) != 4: ipaddress.ip_address(ip.strip())
return False
for part in parts:
num = int(part)
if not 0 <= num <= 255:
return False
return True return True
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 (IPv4/IPv6).
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)
def get_ip_version(ip: str) -> Union[int, None]:
"""
Get the IP version (4 or 6) of a valid IP address.
Args:
ip: IP address string
Returns:
4 for IPv4, 6 for IPv6, None if invalid
"""
try:
addr = ipaddress.ip_address(ip.strip())
return addr.version
except (ValueError, AttributeError):
return None
def normalize_ip(ip: str) -> Union[str, None]:
"""
Normalize an IP address to its canonical form.
Args:
ip: IP address string
Returns:
Normalized IP address string, None if invalid
"""
try:
addr = ipaddress.ip_address(ip.strip())
return str(addr)
except (ValueError, AttributeError):
return None