Add scan cancellation feature
- Replace subprocess.run() with Popen for cancellable processes - Add cancel() method to SneakyScanner with process termination - Track running scanners in registry for stop signal delivery - Handle ScanCancelledError to set scan status to 'cancelled' - Add POST /api/scans/<id>/stop endpoint - Add 'cancelled' as valid scan status - Add Stop button to scans list and detail views - Show cancelled status with warning badge in UI
This commit is contained in:
@@ -14,6 +14,7 @@ from web.auth.decorators import api_auth_required
|
||||
from web.models import Scan, ScanProgress
|
||||
from web.services.scan_service import ScanService
|
||||
from web.utils.pagination import validate_page_params
|
||||
from web.jobs.scan_job import stop_scan
|
||||
|
||||
bp = Blueprint('scans', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -242,6 +243,71 @@ def delete_scan(scan_id):
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id>/stop', methods=['POST'])
|
||||
@api_auth_required
|
||||
def stop_running_scan(scan_id):
|
||||
"""
|
||||
Stop a running scan.
|
||||
|
||||
Args:
|
||||
scan_id: Scan ID to stop
|
||||
|
||||
Returns:
|
||||
JSON response with stop status
|
||||
"""
|
||||
try:
|
||||
session = current_app.db_session
|
||||
|
||||
# Check if scan exists and is running
|
||||
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||
if not scan:
|
||||
logger.warning(f"Scan not found for stop request: {scan_id}")
|
||||
return jsonify({
|
||||
'error': 'Not found',
|
||||
'message': f'Scan with ID {scan_id} not found'
|
||||
}), 404
|
||||
|
||||
if scan.status != 'running':
|
||||
logger.warning(f"Cannot stop scan {scan_id}: status is '{scan.status}'")
|
||||
return jsonify({
|
||||
'error': 'Invalid state',
|
||||
'message': f"Cannot stop scan: status is '{scan.status}'"
|
||||
}), 400
|
||||
|
||||
# Get database URL from app config
|
||||
db_url = current_app.config['SQLALCHEMY_DATABASE_URI']
|
||||
|
||||
# Attempt to stop the scan
|
||||
stopped = stop_scan(scan_id, db_url)
|
||||
|
||||
if stopped:
|
||||
logger.info(f"Stop signal sent to scan {scan_id}")
|
||||
return jsonify({
|
||||
'scan_id': scan_id,
|
||||
'message': 'Stop signal sent to scan',
|
||||
'status': 'stopping'
|
||||
}), 200
|
||||
else:
|
||||
logger.warning(f"Failed to stop scan {scan_id}: not found in running scanners")
|
||||
return jsonify({
|
||||
'error': 'Stop failed',
|
||||
'message': 'Scan not found in running scanners registry'
|
||||
}), 404
|
||||
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"Database error stopping scan {scan_id}: {str(e)}")
|
||||
return jsonify({
|
||||
'error': 'Database error',
|
||||
'message': 'Failed to stop scan'
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error stopping scan {scan_id}: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
'error': 'Internal server error',
|
||||
'message': 'An unexpected error occurred'
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route('/<int:scan_id>/status', methods=['GET'])
|
||||
@api_auth_required
|
||||
def get_scan_status(scan_id):
|
||||
|
||||
@@ -7,6 +7,7 @@ updating database status and handling errors.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -14,13 +15,49 @@ from pathlib import Path
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.scanner import SneakyScanner
|
||||
from src.scanner import SneakyScanner, ScanCancelledError
|
||||
from web.models import Scan, ScanProgress
|
||||
from web.services.scan_service import ScanService
|
||||
from web.services.alert_service import AlertService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Registry for tracking running scanners (scan_id -> SneakyScanner instance)
|
||||
_running_scanners = {}
|
||||
_running_scanners_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_running_scanner(scan_id: int):
|
||||
"""Get a running scanner instance by scan ID."""
|
||||
with _running_scanners_lock:
|
||||
return _running_scanners.get(scan_id)
|
||||
|
||||
|
||||
def stop_scan(scan_id: int, db_url: str) -> bool:
|
||||
"""
|
||||
Stop a running scan.
|
||||
|
||||
Args:
|
||||
scan_id: ID of the scan to stop
|
||||
db_url: Database connection URL
|
||||
|
||||
Returns:
|
||||
True if scan was cancelled, False if not found or already stopped
|
||||
"""
|
||||
logger.info(f"Attempting to stop scan {scan_id}")
|
||||
|
||||
# Get the scanner instance
|
||||
scanner = get_running_scanner(scan_id)
|
||||
if not scanner:
|
||||
logger.warning(f"Scanner for scan {scan_id} not found in registry")
|
||||
return False
|
||||
|
||||
# Cancel the scanner
|
||||
scanner.cancel()
|
||||
logger.info(f"Cancellation signal sent to scan {scan_id}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def create_progress_callback(scan_id: int, session):
|
||||
"""
|
||||
@@ -186,6 +223,11 @@ def execute_scan(scan_id: int, config_id: int, db_url: str = None):
|
||||
# Initialize scanner with database config
|
||||
scanner = SneakyScanner(config_id=config_id)
|
||||
|
||||
# Register scanner in the running registry
|
||||
with _running_scanners_lock:
|
||||
_running_scanners[scan_id] = scanner
|
||||
logger.debug(f"Scan {scan_id}: Registered in running scanners registry")
|
||||
|
||||
# Create progress callback
|
||||
progress_callback = create_progress_callback(scan_id, session)
|
||||
|
||||
@@ -220,6 +262,19 @@ def execute_scan(scan_id: int, config_id: int, db_url: str = None):
|
||||
|
||||
logger.info(f"Scan {scan_id}: Completed successfully")
|
||||
|
||||
except ScanCancelledError:
|
||||
# Scan was cancelled by user
|
||||
logger.info(f"Scan {scan_id}: Cancelled by user")
|
||||
|
||||
scan = session.query(Scan).filter_by(id=scan_id).first()
|
||||
if scan:
|
||||
scan.status = 'cancelled'
|
||||
scan.error_message = 'Scan cancelled by user'
|
||||
scan.completed_at = datetime.utcnow()
|
||||
if scan.started_at:
|
||||
scan.duration = (datetime.utcnow() - scan.started_at).total_seconds()
|
||||
session.commit()
|
||||
|
||||
except FileNotFoundError as e:
|
||||
# Config file not found
|
||||
error_msg = f"Configuration file not found: {str(e)}"
|
||||
@@ -249,6 +304,12 @@ def execute_scan(scan_id: int, config_id: int, db_url: str = None):
|
||||
logger.error(f"Scan {scan_id}: Failed to update error status in database: {str(db_error)}")
|
||||
|
||||
finally:
|
||||
# Unregister scanner from registry
|
||||
with _running_scanners_lock:
|
||||
if scan_id in _running_scanners:
|
||||
del _running_scanners[scan_id]
|
||||
logger.debug(f"Scan {scan_id}: Unregistered from running scanners registry")
|
||||
|
||||
# Always close the session
|
||||
session.close()
|
||||
logger.info(f"Scan {scan_id}: Background job completed, session closed")
|
||||
|
||||
@@ -257,6 +257,9 @@ class ScanService:
|
||||
elif scan.status == 'failed':
|
||||
status_info['progress'] = 'Failed'
|
||||
status_info['error_message'] = scan.error_message
|
||||
elif scan.status == 'cancelled':
|
||||
status_info['progress'] = 'Cancelled'
|
||||
status_info['error_message'] = scan.error_message
|
||||
|
||||
return status_info
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
<span id="refresh-text">Refresh</span>
|
||||
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
||||
</button>
|
||||
<button class="btn btn-warning ms-2" onclick="stopScan()" id="stop-btn" style="display: none;">
|
||||
<span id="stop-text">Stop Scan</span>
|
||||
<span id="stop-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
|
||||
</button>
|
||||
<button class="btn btn-danger ms-2" onclick="deleteScan()" id="delete-btn">Delete Scan</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -471,8 +475,11 @@
|
||||
} else if (scan.status === 'running') {
|
||||
statusBadge = '<span class="badge badge-info">Running</span>';
|
||||
document.getElementById('delete-btn').disabled = true;
|
||||
document.getElementById('stop-btn').style.display = 'inline-block';
|
||||
} else if (scan.status === 'failed') {
|
||||
statusBadge = '<span class="badge badge-danger">Failed</span>';
|
||||
} else if (scan.status === 'cancelled') {
|
||||
statusBadge = '<span class="badge badge-warning">Cancelled</span>';
|
||||
} else {
|
||||
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
|
||||
}
|
||||
@@ -697,6 +704,59 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Stop scan
|
||||
async function stopScan() {
|
||||
if (!confirm(`Are you sure you want to stop scan ${scanId}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stopBtn = document.getElementById('stop-btn');
|
||||
const stopText = document.getElementById('stop-text');
|
||||
const stopSpinner = document.getElementById('stop-spinner');
|
||||
|
||||
// Show loading state
|
||||
stopBtn.disabled = true;
|
||||
stopText.style.display = 'none';
|
||||
stopSpinner.style.display = 'inline-block';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scans/${scanId}/stop`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}: Failed to stop scan`;
|
||||
try {
|
||||
const data = await response.json();
|
||||
errorMessage = data.message || errorMessage;
|
||||
} catch (e) {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Show success message
|
||||
showAlert('success', `Stop signal sent to scan ${scanId}.`);
|
||||
|
||||
// Refresh scan data after a short delay
|
||||
setTimeout(() => {
|
||||
loadScan();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error stopping scan:', error);
|
||||
showAlert('danger', `Failed to stop scan: ${error.message}`);
|
||||
|
||||
// Re-enable button on error
|
||||
stopBtn.disabled = false;
|
||||
stopText.style.display = 'inline';
|
||||
stopSpinner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Find previous scan and show compare button
|
||||
let previousScanId = null;
|
||||
let currentConfigId = null;
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<option value="running">Running</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
@@ -248,20 +249,27 @@
|
||||
statusBadge = '<span class="badge badge-info">Running</span>';
|
||||
} else if (scan.status === 'failed') {
|
||||
statusBadge = '<span class="badge badge-danger">Failed</span>';
|
||||
} else if (scan.status === 'cancelled') {
|
||||
statusBadge = '<span class="badge badge-warning">Cancelled</span>';
|
||||
} else {
|
||||
statusBadge = `<span class="badge badge-info">${scan.status}</span>`;
|
||||
}
|
||||
|
||||
// Action buttons
|
||||
let actionButtons = `<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>`;
|
||||
if (scan.status === 'running') {
|
||||
actionButtons += `<button class="btn btn-sm btn-warning ms-1" onclick="stopScan(${scan.id})">Stop</button>`;
|
||||
} else {
|
||||
actionButtons += `<button class="btn btn-sm btn-danger ms-1" onclick="deleteScan(${scan.id})">Delete</button>`;
|
||||
}
|
||||
|
||||
row.innerHTML = `
|
||||
<td class="mono">${scan.id}</td>
|
||||
<td>${scan.title || 'Untitled Scan'}</td>
|
||||
<td class="text-muted">${timestamp}</td>
|
||||
<td class="mono">${duration}</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>
|
||||
<a href="/scans/${scan.id}" class="btn btn-sm btn-secondary">View</a>
|
||||
${scan.status !== 'running' ? `<button class="btn btn-sm btn-danger ms-1" onclick="deleteScan(${scan.id})">Delete</button>` : ''}
|
||||
</td>
|
||||
<td>${actionButtons}</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
@@ -489,6 +497,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Stop scan
|
||||
async function stopScan(scanId) {
|
||||
if (!confirm(`Are you sure you want to stop scan ${scanId}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/scans/${scanId}/stop`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.message || 'Failed to stop scan');
|
||||
}
|
||||
|
||||
// Show success message
|
||||
showAlert('success', `Stop signal sent to scan ${scanId}.`);
|
||||
|
||||
// Refresh scans after a short delay
|
||||
setTimeout(() => loadScans(), 1000);
|
||||
} catch (error) {
|
||||
console.error('Error stopping scan:', error);
|
||||
showAlert('danger', `Failed to stop scan: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete scan
|
||||
async function deleteScan(scanId) {
|
||||
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {
|
||||
|
||||
@@ -23,7 +23,7 @@ def validate_scan_status(status: str) -> tuple[bool, Optional[str]]:
|
||||
>>> validate_scan_status('invalid')
|
||||
(False, 'Invalid status: invalid. Must be one of: running, completed, failed')
|
||||
"""
|
||||
valid_statuses = ['running', 'completed', 'failed']
|
||||
valid_statuses = ['running', 'completed', 'failed', 'cancelled']
|
||||
|
||||
if status not in valid_statuses:
|
||||
return False, f'Invalid status: {status}. Must be one of: {", ".join(valid_statuses)}'
|
||||
|
||||
Reference in New Issue
Block a user