hot fixes for several UI and logic issues
This commit is contained in:
@@ -1,14 +0,0 @@
|
|||||||
title: DMZ
|
|
||||||
sites:
|
|
||||||
- name: DMZ Reverse Proxys
|
|
||||||
ips:
|
|
||||||
- address: 10.10.99.10
|
|
||||||
expected:
|
|
||||||
ping: true
|
|
||||||
tcp_ports: [22,80,443]
|
|
||||||
udp_ports: []
|
|
||||||
- address: 10.10.99.20
|
|
||||||
expected:
|
|
||||||
ping: true
|
|
||||||
tcp_ports: [22,80,443]
|
|
||||||
udp_ports: []
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
title: "Sneaky Infra Scan"
|
|
||||||
sites:
|
|
||||||
- name: "Production Web Servers"
|
|
||||||
ips:
|
|
||||||
- address: "10.10.20.4"
|
|
||||||
expected:
|
|
||||||
ping: true
|
|
||||||
tcp_ports: [22, 53, 80]
|
|
||||||
udp_ports: [53]
|
|
||||||
# Optional: specify expected services (detected automatically)
|
|
||||||
services: ["ssh", "domain", "http"]
|
|
||||||
- address: "10.10.20.11"
|
|
||||||
expected:
|
|
||||||
ping: true
|
|
||||||
tcp_ports: [22, 111, 3128, 8006]
|
|
||||||
udp_ports: []
|
|
||||||
@@ -215,23 +215,63 @@ Missing,Columns,Here
|
|||||||
service.delete_config('nonexistent.yaml')
|
service.delete_config('nonexistent.yaml')
|
||||||
|
|
||||||
def test_delete_config_used_by_schedule(self, service, temp_configs_dir, sample_yaml_config, monkeypatch):
|
def test_delete_config_used_by_schedule(self, service, temp_configs_dir, sample_yaml_config, monkeypatch):
|
||||||
"""Test deleting config that is used by schedules"""
|
"""Test deleting config that is used by schedules - should cascade delete schedules"""
|
||||||
# Create a config file
|
# Create a config file
|
||||||
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
||||||
with open(config_path, 'w') as f:
|
with open(config_path, 'w') as f:
|
||||||
f.write(sample_yaml_config)
|
f.write(sample_yaml_config)
|
||||||
|
|
||||||
# Mock get_schedules_using_config to return schedules
|
# Mock schedule service interactions
|
||||||
def mock_get_schedules(filename):
|
deleted_schedule_ids = []
|
||||||
return ['Daily Scan', 'Weekly Audit']
|
|
||||||
|
|
||||||
monkeypatch.setattr(service, 'get_schedules_using_config', mock_get_schedules)
|
class MockScheduleService:
|
||||||
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="used by the following schedules"):
|
def list_schedules(self, page=1, per_page=10000):
|
||||||
|
return {
|
||||||
|
'schedules': [
|
||||||
|
{
|
||||||
|
'id': 1,
|
||||||
|
'name': 'Daily Scan',
|
||||||
|
'config_file': 'test-scan.yaml',
|
||||||
|
'enabled': True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 2,
|
||||||
|
'name': 'Weekly Audit',
|
||||||
|
'config_file': 'test-scan.yaml',
|
||||||
|
'enabled': False # Disabled schedule should also be deleted
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def delete_schedule(self, schedule_id):
|
||||||
|
deleted_schedule_ids.append(schedule_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Mock the ScheduleService import
|
||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
mock_module = MagicMock()
|
||||||
|
mock_module.ScheduleService = MockScheduleService
|
||||||
|
monkeypatch.setitem(sys.modules, 'web.services.schedule_service', mock_module)
|
||||||
|
|
||||||
|
# Mock current_app
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.db_session = MagicMock()
|
||||||
|
|
||||||
|
import flask
|
||||||
|
monkeypatch.setattr(flask, 'current_app', mock_app)
|
||||||
|
|
||||||
|
# Delete the config - should cascade delete associated schedules
|
||||||
service.delete_config('test-scan.yaml')
|
service.delete_config('test-scan.yaml')
|
||||||
|
|
||||||
# Config should still exist
|
# Config should be deleted
|
||||||
assert service.config_exists('test-scan.yaml')
|
assert not service.config_exists('test-scan.yaml')
|
||||||
|
|
||||||
|
# Both schedules (enabled and disabled) should be deleted
|
||||||
|
assert deleted_schedule_ids == [1, 2]
|
||||||
|
|
||||||
def test_validate_config_content_valid(self, service):
|
def test_validate_config_content_valid(self, service):
|
||||||
"""Test validating valid config content"""
|
"""Test validating valid config content"""
|
||||||
|
|||||||
@@ -404,7 +404,10 @@ def update_config(filename: str):
|
|||||||
@api_auth_required
|
@api_auth_required
|
||||||
def delete_config(filename: str):
|
def delete_config(filename: str):
|
||||||
"""
|
"""
|
||||||
Delete config file.
|
Delete config file and cascade delete associated schedules.
|
||||||
|
|
||||||
|
When a config is deleted, all schedules using that config (both enabled
|
||||||
|
and disabled) are automatically deleted as well.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename: Config filename
|
filename: Config filename
|
||||||
@@ -418,7 +421,6 @@ def delete_config(filename: str):
|
|||||||
|
|
||||||
Error responses:
|
Error responses:
|
||||||
- 404: Config file not found
|
- 404: Config file not found
|
||||||
- 422: Config is used by schedules (cannot delete)
|
|
||||||
- 500: Internal server error
|
- 500: Internal server error
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@@ -442,14 +444,6 @@ def delete_config(filename: str):
|
|||||||
'message': str(e)
|
'message': str(e)
|
||||||
}), 404
|
}), 404
|
||||||
|
|
||||||
except ValueError as e:
|
|
||||||
# Config is used by schedules
|
|
||||||
logger.warning(f"Cannot delete config {filename}: {str(e)}")
|
|
||||||
return jsonify({
|
|
||||||
'error': 'Config in use',
|
|
||||||
'message': str(e)
|
|
||||||
}), 422
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Unexpected error deleting config {filename}: {str(e)}", exc_info=True)
|
logger.error(f"Unexpected error deleting config {filename}: {str(e)}", exc_info=True)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
|
|||||||
@@ -301,26 +301,71 @@ class ConfigService:
|
|||||||
|
|
||||||
def delete_config(self, filename: str) -> None:
|
def delete_config(self, filename: str) -> None:
|
||||||
"""
|
"""
|
||||||
Delete config file if not used by schedules.
|
Delete config file and cascade delete any associated schedules.
|
||||||
|
|
||||||
|
When a config is deleted, all schedules using that config (both enabled
|
||||||
|
and disabled) are automatically deleted as well, since they would be
|
||||||
|
invalid without the config file.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
filename: Config filename to delete
|
filename: Config filename to delete
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: If config doesn't exist
|
FileNotFoundError: If config doesn't exist
|
||||||
ValueError: If config used by active schedules
|
|
||||||
"""
|
"""
|
||||||
filepath = os.path.join(self.configs_dir, filename)
|
filepath = os.path.join(self.configs_dir, filename)
|
||||||
|
|
||||||
if not os.path.exists(filepath):
|
if not os.path.exists(filepath):
|
||||||
raise FileNotFoundError(f"Config file '{filename}' not found")
|
raise FileNotFoundError(f"Config file '{filename}' not found")
|
||||||
|
|
||||||
# Check if used by schedules
|
# Delete any schedules using this config (both enabled and disabled)
|
||||||
schedules = self.get_schedules_using_config(filename)
|
try:
|
||||||
if schedules:
|
from web.services.schedule_service import ScheduleService
|
||||||
schedule_list = ', '.join(schedules)
|
from flask import current_app
|
||||||
raise ValueError(
|
|
||||||
f"Cannot delete config '{filename}' because it is used by the following schedules: {schedule_list}"
|
# Get database session from Flask app
|
||||||
|
db = current_app.db_session
|
||||||
|
|
||||||
|
# Get all schedules
|
||||||
|
schedule_service = ScheduleService(db)
|
||||||
|
result = schedule_service.list_schedules(page=1, per_page=10000)
|
||||||
|
schedules = result.get('schedules', [])
|
||||||
|
|
||||||
|
# Build full path for comparison
|
||||||
|
config_path = os.path.join(self.configs_dir, filename)
|
||||||
|
|
||||||
|
# Find and delete all schedules using this config (enabled or disabled)
|
||||||
|
deleted_schedules = []
|
||||||
|
for schedule in schedules:
|
||||||
|
schedule_config = schedule.get('config_file', '')
|
||||||
|
|
||||||
|
# Handle both absolute paths and just filenames
|
||||||
|
if schedule_config == filename or schedule_config == config_path:
|
||||||
|
schedule_id = schedule.get('id')
|
||||||
|
schedule_name = schedule.get('name', 'Unknown')
|
||||||
|
try:
|
||||||
|
schedule_service.delete_schedule(schedule_id)
|
||||||
|
deleted_schedules.append(schedule_name)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
f"Failed to delete schedule {schedule_id} ('{schedule_name}'): {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if deleted_schedules:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).info(
|
||||||
|
f"Cascade deleted {len(deleted_schedules)} schedule(s) associated with config '{filename}': {', '.join(deleted_schedules)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# If ScheduleService doesn't exist yet, skip schedule deletion
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
# Log error but continue with config deletion
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).error(
|
||||||
|
f"Error deleting schedules for config {filename}: {e}", exc_info=True
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delete file
|
# Delete file
|
||||||
@@ -404,21 +449,30 @@ class ConfigService:
|
|||||||
# Import here to avoid circular dependency
|
# Import here to avoid circular dependency
|
||||||
try:
|
try:
|
||||||
from web.services.schedule_service import ScheduleService
|
from web.services.schedule_service import ScheduleService
|
||||||
schedule_service = ScheduleService()
|
from flask import current_app
|
||||||
|
|
||||||
# Get all schedules
|
# Get database session from Flask app
|
||||||
schedules = schedule_service.list_schedules()
|
db = current_app.db_session
|
||||||
|
|
||||||
|
# Get all schedules (use large per_page to get all)
|
||||||
|
schedule_service = ScheduleService(db)
|
||||||
|
result = schedule_service.list_schedules(page=1, per_page=10000)
|
||||||
|
|
||||||
|
# Extract schedules list from paginated result
|
||||||
|
schedules = result.get('schedules', [])
|
||||||
|
|
||||||
# Build full path for comparison
|
# Build full path for comparison
|
||||||
config_path = os.path.join(self.configs_dir, filename)
|
config_path = os.path.join(self.configs_dir, filename)
|
||||||
|
|
||||||
# Find schedules using this config
|
# Find schedules using this config (only enabled schedules)
|
||||||
using_schedules = []
|
using_schedules = []
|
||||||
for schedule in schedules:
|
for schedule in schedules:
|
||||||
schedule_config = schedule.get('config_file', '')
|
schedule_config = schedule.get('config_file', '')
|
||||||
|
|
||||||
# Handle both absolute paths and just filenames
|
# Handle both absolute paths and just filenames
|
||||||
if schedule_config == filename or schedule_config == config_path:
|
if schedule_config == filename or schedule_config == config_path:
|
||||||
|
# Only count enabled schedules
|
||||||
|
if schedule.get('enabled', False):
|
||||||
using_schedules.append(schedule.get('name', 'Unknown'))
|
using_schedules.append(schedule.get('name', 'Unknown'))
|
||||||
|
|
||||||
return using_schedules
|
return using_schedules
|
||||||
@@ -426,8 +480,11 @@ class ConfigService:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
# If ScheduleService doesn't exist yet, return empty list
|
# If ScheduleService doesn't exist yet, return empty list
|
||||||
return []
|
return []
|
||||||
except Exception:
|
except Exception as e:
|
||||||
# If any error occurs, return empty list (safer than failing)
|
# If any error occurs, return empty list (safer than failing)
|
||||||
|
# Log the error for debugging
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).error(f"Error getting schedules using config {filename}: {e}", exc_info=True)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def generate_filename_from_title(self, title: str) -> str:
|
def generate_filename_from_title(self, title: str) -> str:
|
||||||
|
|||||||
@@ -561,6 +561,7 @@ class ScanService:
|
|||||||
'duration': scan.duration,
|
'duration': scan.duration,
|
||||||
'status': scan.status,
|
'status': scan.status,
|
||||||
'title': scan.title,
|
'title': scan.title,
|
||||||
|
'config_file': scan.config_file,
|
||||||
'triggered_by': scan.triggered_by,
|
'triggered_by': scan.triggered_by,
|
||||||
'created_at': scan.created_at.isoformat() if scan.created_at else None
|
'created_at': scan.created_at.isoformat() if scan.created_at else None
|
||||||
}
|
}
|
||||||
@@ -675,6 +676,8 @@ class ScanService:
|
|||||||
{
|
{
|
||||||
'scan1': {...}, # Scan 1 summary
|
'scan1': {...}, # Scan 1 summary
|
||||||
'scan2': {...}, # Scan 2 summary
|
'scan2': {...}, # Scan 2 summary
|
||||||
|
'same_config': bool, # Whether both scans used the same config
|
||||||
|
'config_warning': str | None, # Warning message if configs differ
|
||||||
'ports': {
|
'ports': {
|
||||||
'added': [...],
|
'added': [...],
|
||||||
'removed': [...],
|
'removed': [...],
|
||||||
@@ -700,6 +703,22 @@ class ScanService:
|
|||||||
if not scan1 or not scan2:
|
if not scan1 or not scan2:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Check if scans use the same configuration
|
||||||
|
config1 = scan1.get('config_file', '')
|
||||||
|
config2 = scan2.get('config_file', '')
|
||||||
|
same_config = (config1 == config2) and (config1 != '')
|
||||||
|
|
||||||
|
# Generate warning message if configs differ
|
||||||
|
config_warning = None
|
||||||
|
if not same_config:
|
||||||
|
config_warning = (
|
||||||
|
f"These scans use different configurations. "
|
||||||
|
f"Scan #{scan1_id} used '{config1 or 'unknown'}' and "
|
||||||
|
f"Scan #{scan2_id} used '{config2 or 'unknown'}'. "
|
||||||
|
f"The comparison may show all changes as additions/removals if the scans "
|
||||||
|
f"cover different IP ranges or infrastructure."
|
||||||
|
)
|
||||||
|
|
||||||
# Extract port data
|
# Extract port data
|
||||||
ports1 = self._extract_ports_from_scan(scan1)
|
ports1 = self._extract_ports_from_scan(scan1)
|
||||||
ports2 = self._extract_ports_from_scan(scan2)
|
ports2 = self._extract_ports_from_scan(scan2)
|
||||||
@@ -733,14 +752,18 @@ class ScanService:
|
|||||||
'id': scan1['id'],
|
'id': scan1['id'],
|
||||||
'timestamp': scan1['timestamp'],
|
'timestamp': scan1['timestamp'],
|
||||||
'title': scan1['title'],
|
'title': scan1['title'],
|
||||||
'status': scan1['status']
|
'status': scan1['status'],
|
||||||
|
'config_file': config1
|
||||||
},
|
},
|
||||||
'scan2': {
|
'scan2': {
|
||||||
'id': scan2['id'],
|
'id': scan2['id'],
|
||||||
'timestamp': scan2['timestamp'],
|
'timestamp': scan2['timestamp'],
|
||||||
'title': scan2['title'],
|
'title': scan2['title'],
|
||||||
'status': scan2['status']
|
'status': scan2['status'],
|
||||||
|
'config_file': config2
|
||||||
},
|
},
|
||||||
|
'same_config': same_config,
|
||||||
|
'config_warning': config_warning,
|
||||||
'ports': ports_comparison,
|
'ports': ports_comparison,
|
||||||
'services': services_comparison,
|
'services': services_comparison,
|
||||||
'certificates': certificates_comparison,
|
'certificates': certificates_comparison,
|
||||||
|
|||||||
@@ -154,26 +154,33 @@
|
|||||||
<form id="trigger-scan-form">
|
<form id="trigger-scan-form">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="config-file" class="form-label">Config File</label>
|
<label for="config-file" class="form-label">Config File</label>
|
||||||
<select class="form-select" id="config-file" name="config_file" required>
|
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
|
||||||
<option value="">Select a config file...</option>
|
<option value="">Select a config file...</option>
|
||||||
{% for config in config_files %}
|
{% for config in config_files %}
|
||||||
<option value="{{ config }}">{{ config }}</option>
|
<option value="{{ config }}">{{ config }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text text-muted">
|
|
||||||
{% if config_files %}
|
{% if config_files %}
|
||||||
|
<div class="form-text text-muted">
|
||||||
Select a scan configuration file
|
Select a scan configuration file
|
||||||
{% else %}
|
|
||||||
<span class="text-warning">No config files found in /app/configs/</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-warning mt-2 mb-0" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
<strong>No configurations available</strong>
|
||||||
|
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
|
||||||
|
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create Configuration
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" onclick="triggerScan()">
|
<button type="button" class="btn btn-primary" onclick="triggerScan()" {% if not config_files %}disabled{% endif %}>
|
||||||
<span id="modal-trigger-text">Trigger Scan</span>
|
<span id="modal-trigger-text">Trigger Scan</span>
|
||||||
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -28,6 +28,13 @@
|
|||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div id="comparison-error" class="alert alert-danger" style="display: none;"></div>
|
<div id="comparison-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
|
|
||||||
|
<!-- Config Warning -->
|
||||||
|
<div id="config-warning" class="alert alert-warning" style="display: none;">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
<strong>Different Configurations Detected</strong>
|
||||||
|
<p class="mb-0" id="config-warning-message"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Comparison Content -->
|
<!-- Comparison Content -->
|
||||||
<div id="comparison-content" style="display: none;">
|
<div id="comparison-content" style="display: none;">
|
||||||
<!-- Drift Score Card -->
|
<!-- Drift Score Card -->
|
||||||
@@ -52,14 +59,16 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label text-muted">Older Scan (#<span id="scan1-id"></span>)</label>
|
<label class="form-label text-muted">Older Scan (#<span id="scan1-id"></span>)</label>
|
||||||
<div id="scan1-title" class="fw-bold">-</div>
|
<div id="scan1-title" class="fw-bold">-</div>
|
||||||
<small class="text-muted" id="scan1-timestamp">-</small>
|
<small class="text-muted d-block" id="scan1-timestamp">-</small>
|
||||||
|
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan1-config">-</span></small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label text-muted">Newer Scan (#<span id="scan2-id"></span>)</label>
|
<label class="form-label text-muted">Newer Scan (#<span id="scan2-id"></span>)</label>
|
||||||
<div id="scan2-title" class="fw-bold">-</div>
|
<div id="scan2-title" class="fw-bold">-</div>
|
||||||
<small class="text-muted" id="scan2-timestamp">-</small>
|
<small class="text-muted d-block" id="scan2-timestamp">-</small>
|
||||||
|
<small class="text-muted d-block"><i class="bi bi-file-earmark-text"></i> <span id="scan2-config">-</span></small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
@@ -340,6 +349,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function populateComparison(data) {
|
function populateComparison(data) {
|
||||||
|
// Show config warning if configs differ
|
||||||
|
if (data.config_warning) {
|
||||||
|
const warningDiv = document.getElementById('config-warning');
|
||||||
|
const warningMessage = document.getElementById('config-warning-message');
|
||||||
|
warningMessage.textContent = data.config_warning;
|
||||||
|
warningDiv.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
// Drift score
|
// Drift score
|
||||||
const driftScore = data.drift_score || 0;
|
const driftScore = data.drift_score || 0;
|
||||||
document.getElementById('drift-score').textContent = driftScore.toFixed(3);
|
document.getElementById('drift-score').textContent = driftScore.toFixed(3);
|
||||||
@@ -358,10 +375,12 @@
|
|||||||
document.getElementById('scan1-id').textContent = data.scan1.id;
|
document.getElementById('scan1-id').textContent = data.scan1.id;
|
||||||
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
|
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
|
||||||
document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString();
|
document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString();
|
||||||
|
document.getElementById('scan1-config').textContent = data.scan1.config_file || 'Unknown';
|
||||||
|
|
||||||
document.getElementById('scan2-id').textContent = data.scan2.id;
|
document.getElementById('scan2-id').textContent = data.scan2.id;
|
||||||
document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan';
|
document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan';
|
||||||
document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString();
|
document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString();
|
||||||
|
document.getElementById('scan2-config').textContent = data.scan2.config_file || 'Unknown';
|
||||||
|
|
||||||
// Ports comparison
|
// Ports comparison
|
||||||
populatePortsComparison(data.ports);
|
populatePortsComparison(data.ports);
|
||||||
|
|||||||
@@ -457,24 +457,44 @@
|
|||||||
|
|
||||||
// Find previous scan and show compare button
|
// Find previous scan and show compare button
|
||||||
let previousScanId = null;
|
let previousScanId = null;
|
||||||
|
let currentConfigFile = null;
|
||||||
async function findPreviousScan() {
|
async function findPreviousScan() {
|
||||||
try {
|
try {
|
||||||
// Get list of scans to find the previous one
|
// Get current scan details first to know which config it used
|
||||||
|
const currentScanResponse = await fetch(`/api/scans/${scanId}`);
|
||||||
|
const currentScanData = await currentScanResponse.json();
|
||||||
|
currentConfigFile = currentScanData.config_file;
|
||||||
|
|
||||||
|
// Get list of completed scans
|
||||||
const response = await fetch('/api/scans?per_page=100&status=completed');
|
const response = await fetch('/api/scans?per_page=100&status=completed');
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.scans && data.scans.length > 0) {
|
if (data.scans && data.scans.length > 0) {
|
||||||
// Find scans older than current scan
|
// Find the current scan
|
||||||
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
|
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
|
||||||
|
|
||||||
if (currentScanIndex !== -1 && currentScanIndex < data.scans.length - 1) {
|
if (currentScanIndex !== -1) {
|
||||||
// Get the next scan in the list (which is older due to desc order)
|
// Look for the most recent previous scan with the SAME config file
|
||||||
previousScanId = data.scans[currentScanIndex + 1].id;
|
for (let i = currentScanIndex + 1; i < data.scans.length; i++) {
|
||||||
|
const previousScan = data.scans[i];
|
||||||
|
|
||||||
|
// Check if this scan uses the same config
|
||||||
|
if (previousScan.config_file === currentConfigFile) {
|
||||||
|
previousScanId = previousScan.id;
|
||||||
|
|
||||||
// Show the compare button
|
// Show the compare button
|
||||||
const compareBtn = document.getElementById('compare-btn');
|
const compareBtn = document.getElementById('compare-btn');
|
||||||
if (compareBtn) {
|
if (compareBtn) {
|
||||||
compareBtn.style.display = 'inline-block';
|
compareBtn.style.display = 'inline-block';
|
||||||
|
compareBtn.title = `Compare with Scan #${previousScanId} (same config)`;
|
||||||
|
}
|
||||||
|
break; // Found the most recent matching scan
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no matching config found, don't show compare button
|
||||||
|
if (!previousScanId) {
|
||||||
|
console.log('No previous scans found with the same configuration');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,26 +114,33 @@
|
|||||||
<form id="trigger-scan-form">
|
<form id="trigger-scan-form">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="config-file" class="form-label">Config File</label>
|
<label for="config-file" class="form-label">Config File</label>
|
||||||
<select class="form-select" id="config-file" name="config_file" required>
|
<select class="form-select" id="config-file" name="config_file" required {% if not config_files %}disabled{% endif %}>
|
||||||
<option value="">Select a config file...</option>
|
<option value="">Select a config file...</option>
|
||||||
{% for config in config_files %}
|
{% for config in config_files %}
|
||||||
<option value="{{ config }}">{{ config }}</option>
|
<option value="{{ config }}">{{ config }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<div class="form-text text-muted">
|
|
||||||
{% if config_files %}
|
{% if config_files %}
|
||||||
|
<div class="form-text text-muted">
|
||||||
Select a scan configuration file
|
Select a scan configuration file
|
||||||
{% else %}
|
|
||||||
<span class="text-warning">No config files found in /app/configs/</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-warning mt-2 mb-0" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
<strong>No configurations available</strong>
|
||||||
|
<p class="mb-2 mt-2">You need to create a configuration file before you can trigger a scan.</p>
|
||||||
|
<a href="{{ url_for('main.upload_config') }}" class="btn btn-sm btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Create Configuration
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
<div id="trigger-error" class="alert alert-danger" style="display: none;"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
<div class="modal-footer" style="border-top: 1px solid #334155;">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" onclick="triggerScan()">
|
<button type="button" class="btn btn-primary" onclick="triggerScan()" {% if not config_files %}disabled{% endif %}>
|
||||||
<span id="modal-trigger-text">Trigger Scan</span>
|
<span id="modal-trigger-text">Trigger Scan</span>
|
||||||
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
<span id="modal-trigger-spinner" class="spinner-border spinner-border-sm ms-2" style="display: none;"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -420,7 +420,8 @@ document.getElementById('create-schedule-form').addEventListener('submit', async
|
|||||||
// Show notification
|
// Show notification
|
||||||
function showNotification(message, type = 'info') {
|
function showNotification(message, type = 'info') {
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
||||||
|
notification.style.position = 'fixed';
|
||||||
notification.style.top = '20px';
|
notification.style.top = '20px';
|
||||||
notification.style.right = '20px';
|
notification.style.right = '20px';
|
||||||
notification.style.zIndex = '9999';
|
notification.style.zIndex = '9999';
|
||||||
|
|||||||
@@ -555,7 +555,8 @@ async function deleteSchedule() {
|
|||||||
// Show notification
|
// Show notification
|
||||||
function showNotification(message, type = 'info') {
|
function showNotification(message, type = 'info') {
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
||||||
|
notification.style.position = 'fixed';
|
||||||
notification.style.top = '20px';
|
notification.style.top = '20px';
|
||||||
notification.style.right = '20px';
|
notification.style.right = '20px';
|
||||||
notification.style.zIndex = '9999';
|
notification.style.zIndex = '9999';
|
||||||
|
|||||||
@@ -354,7 +354,8 @@ async function deleteSchedule(scheduleId) {
|
|||||||
function showNotification(message, type = 'info') {
|
function showNotification(message, type = 'info') {
|
||||||
// Create notification element
|
// Create notification element
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
||||||
|
notification.style.position = 'fixed';
|
||||||
notification.style.top = '20px';
|
notification.style.top = '20px';
|
||||||
notification.style.right = '20px';
|
notification.style.right = '20px';
|
||||||
notification.style.zIndex = '9999';
|
notification.style.zIndex = '9999';
|
||||||
|
|||||||
Reference in New Issue
Block a user