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:
2025-11-21 14:17:26 -06:00
parent 04dc238aea
commit 3058c69c39
7 changed files with 358 additions and 15 deletions

View File

@@ -7,9 +7,11 @@ import argparse
import json import json
import logging import logging
import os import os
import signal
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import threading
import time import time
import zipfile import zipfile
from datetime import datetime from datetime import datetime
@@ -30,6 +32,11 @@ sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True) sys.stderr.reconfigure(line_buffering=True)
class ScanCancelledError(Exception):
"""Raised when a scan is cancelled by the user."""
pass
class SneakyScanner: class SneakyScanner:
"""Wrapper for masscan to perform network scans based on YAML config or database config""" """Wrapper for masscan to perform network scans based on YAML config or database config"""
@@ -63,6 +70,34 @@ class SneakyScanner:
self.screenshot_capture = None self.screenshot_capture = None
# Cancellation support
self._cancelled = False
self._cancel_lock = threading.Lock()
self._active_process = None
self._process_lock = threading.Lock()
def cancel(self):
"""
Cancel the running scan.
Terminates any active subprocess and sets cancellation flag.
"""
with self._cancel_lock:
self._cancelled = True
with self._process_lock:
if self._active_process and self._active_process.poll() is None:
try:
# Terminate the process group
os.killpg(os.getpgid(self._active_process.pid), signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
def is_cancelled(self) -> bool:
"""Check if scan has been cancelled."""
with self._cancel_lock:
return self._cancelled
def _load_config(self) -> Dict[str, Any]: def _load_config(self) -> Dict[str, Any]:
""" """
Load and validate configuration from file or database. Load and validate configuration from file or database.
@@ -383,11 +418,31 @@ class SneakyScanner:
raise ValueError(f"Invalid protocol: {protocol}") raise ValueError(f"Invalid protocol: {protocol}")
print(f"Running: {' '.join(cmd)}", flush=True) print(f"Running: {' '.join(cmd)}", flush=True)
result = subprocess.run(cmd, capture_output=True, text=True)
# Use Popen for cancellation support
with self._process_lock:
self._active_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
start_new_session=True
)
stdout, stderr = self._active_process.communicate()
returncode = self._active_process.returncode
with self._process_lock:
self._active_process = None
# Check if cancelled
if self.is_cancelled():
return []
print(f"Masscan {protocol.upper()} scan completed", flush=True) print(f"Masscan {protocol.upper()} scan completed", flush=True)
if result.returncode != 0: if returncode != 0:
print(f"Masscan stderr: {result.stderr}", file=sys.stderr) print(f"Masscan stderr: {stderr}", file=sys.stderr)
# Parse masscan JSON output # Parse masscan JSON output
results = [] results = []
@@ -435,11 +490,31 @@ class SneakyScanner:
] ]
print(f"Running: {' '.join(cmd)}", flush=True) print(f"Running: {' '.join(cmd)}", flush=True)
result = subprocess.run(cmd, capture_output=True, text=True)
# Use Popen for cancellation support
with self._process_lock:
self._active_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
start_new_session=True
)
stdout, stderr = self._active_process.communicate()
returncode = self._active_process.returncode
with self._process_lock:
self._active_process = None
# Check if cancelled
if self.is_cancelled():
return {}
print(f"Masscan PING scan completed", flush=True) print(f"Masscan PING scan completed", flush=True)
if result.returncode != 0: if returncode != 0:
print(f"Masscan stderr: {result.stderr}", file=sys.stderr, flush=True) print(f"Masscan stderr: {stderr}", file=sys.stderr, flush=True)
# Parse results # Parse results
responding_ips = set() responding_ips = set()
@@ -477,6 +552,10 @@ class SneakyScanner:
all_services = {} all_services = {}
for ip, ports in ip_ports.items(): for ip, ports in ip_ports.items():
# Check if cancelled before each host
if self.is_cancelled():
break
if not ports: if not ports:
all_services[ip] = [] all_services[ip] = []
continue continue
@@ -502,10 +581,29 @@ class SneakyScanner:
ip ip
] ]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) # Use Popen for cancellation support
with self._process_lock:
self._active_process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
start_new_session=True
)
if result.returncode != 0: stdout, stderr = self._active_process.communicate(timeout=600)
print(f" Nmap warning for {ip}: {result.stderr}", file=sys.stderr, flush=True) returncode = self._active_process.returncode
with self._process_lock:
self._active_process = None
# Check if cancelled
if self.is_cancelled():
Path(xml_output).unlink(missing_ok=True)
break
if returncode != 0:
print(f" Nmap warning for {ip}: {stderr}", file=sys.stderr, flush=True)
# Parse XML output # Parse XML output
services = self._parse_nmap_xml(xml_output) services = self._parse_nmap_xml(xml_output)
@@ -894,6 +992,11 @@ class SneakyScanner:
progress_callback('ping', None, {'status': 'starting'}) progress_callback('ping', None, {'status': 'starting'})
ping_results = self._run_ping_scan(all_ips) ping_results = self._run_ping_scan(all_ips)
# Check for cancellation
if self.is_cancelled():
print("\nScan cancelled by user", flush=True)
raise ScanCancelledError("Scan cancelled by user")
# Report ping results # Report ping results
if progress_callback: if progress_callback:
progress_callback('ping', None, { progress_callback('ping', None, {
@@ -907,6 +1010,11 @@ class SneakyScanner:
progress_callback('tcp_scan', None, {'status': 'starting'}) progress_callback('tcp_scan', None, {'status': 'starting'})
tcp_results = self._run_masscan(all_ips, '0-65535', 'tcp') tcp_results = self._run_masscan(all_ips, '0-65535', 'tcp')
# Check for cancellation
if self.is_cancelled():
print("\nScan cancelled by user", flush=True)
raise ScanCancelledError("Scan cancelled by user")
# Perform UDP scan (if enabled) # Perform UDP scan (if enabled)
udp_enabled = os.environ.get('UDP_SCAN_ENABLED', 'false').lower() == 'true' udp_enabled = os.environ.get('UDP_SCAN_ENABLED', 'false').lower() == 'true'
udp_ports = os.environ.get('UDP_PORTS', '53,67,68,69,123,161,500,514,1900') udp_ports = os.environ.get('UDP_PORTS', '53,67,68,69,123,161,500,514,1900')
@@ -916,6 +1024,11 @@ class SneakyScanner:
if progress_callback: if progress_callback:
progress_callback('udp_scan', None, {'status': 'starting'}) progress_callback('udp_scan', None, {'status': 'starting'})
udp_results = self._run_masscan(all_ips, udp_ports, 'udp') udp_results = self._run_masscan(all_ips, udp_ports, 'udp')
# Check for cancellation
if self.is_cancelled():
print("\nScan cancelled by user", flush=True)
raise ScanCancelledError("Scan cancelled by user")
else: else:
print(f"\n[3/5] Skipping UDP scan (disabled)...", flush=True) print(f"\n[3/5] Skipping UDP scan (disabled)...", flush=True)
if progress_callback: if progress_callback:
@@ -975,6 +1088,11 @@ class SneakyScanner:
ip_ports = {ip: results_by_ip[ip]['actual']['tcp_ports'] for ip in all_ips} ip_ports = {ip: results_by_ip[ip]['actual']['tcp_ports'] for ip in all_ips}
service_results = self._run_nmap_service_detection(ip_ports) service_results = self._run_nmap_service_detection(ip_ports)
# Check for cancellation
if self.is_cancelled():
print("\nScan cancelled by user", flush=True)
raise ScanCancelledError("Scan cancelled by user")
# Add service information to results # Add service information to results
for ip, services in service_results.items(): for ip, services in service_results.items():
if ip in results_by_ip: if ip in results_by_ip:

View File

@@ -14,6 +14,7 @@ from web.auth.decorators import api_auth_required
from web.models import Scan, ScanProgress from web.models import Scan, ScanProgress
from web.services.scan_service import ScanService from web.services.scan_service import ScanService
from web.utils.pagination import validate_page_params from web.utils.pagination import validate_page_params
from web.jobs.scan_job import stop_scan
bp = Blueprint('scans', __name__) bp = Blueprint('scans', __name__)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -242,6 +243,71 @@ def delete_scan(scan_id):
}), 500 }), 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']) @bp.route('/<int:scan_id>/status', methods=['GET'])
@api_auth_required @api_auth_required
def get_scan_status(scan_id): def get_scan_status(scan_id):

View File

@@ -7,6 +7,7 @@ updating database status and handling errors.
import json import json
import logging import logging
import threading
import traceback import traceback
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -14,13 +15,49 @@ from pathlib import Path
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from src.scanner import SneakyScanner from src.scanner import SneakyScanner, ScanCancelledError
from web.models import Scan, ScanProgress from web.models import Scan, ScanProgress
from web.services.scan_service import ScanService from web.services.scan_service import ScanService
from web.services.alert_service import AlertService from web.services.alert_service import AlertService
logger = logging.getLogger(__name__) 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): 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 # Initialize scanner with database config
scanner = SneakyScanner(config_id=config_id) 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 # Create progress callback
progress_callback = create_progress_callback(scan_id, session) 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") 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: except FileNotFoundError as e:
# Config file not found # Config file not found
error_msg = f"Configuration file not found: {str(e)}" 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)}") logger.error(f"Scan {scan_id}: Failed to update error status in database: {str(db_error)}")
finally: 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 # Always close the session
session.close() session.close()
logger.info(f"Scan {scan_id}: Background job completed, session closed") logger.info(f"Scan {scan_id}: Background job completed, session closed")

View File

@@ -257,6 +257,9 @@ class ScanService:
elif scan.status == 'failed': elif scan.status == 'failed':
status_info['progress'] = 'Failed' status_info['progress'] = 'Failed'
status_info['error_message'] = scan.error_message 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 return status_info

View File

@@ -20,6 +20,10 @@
<span id="refresh-text">Refresh</span> <span id="refresh-text">Refresh</span>
<span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span> <span id="refresh-spinner" class="spinner-border spinner-border-sm ms-1" style="display: none;"></span>
</button> </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> <button class="btn btn-danger ms-2" onclick="deleteScan()" id="delete-btn">Delete Scan</button>
</div> </div>
</div> </div>
@@ -471,8 +475,11 @@
} else if (scan.status === 'running') { } else if (scan.status === 'running') {
statusBadge = '<span class="badge badge-info">Running</span>'; statusBadge = '<span class="badge badge-info">Running</span>';
document.getElementById('delete-btn').disabled = true; document.getElementById('delete-btn').disabled = true;
document.getElementById('stop-btn').style.display = 'inline-block';
} else if (scan.status === 'failed') { } else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</span>'; statusBadge = '<span class="badge badge-danger">Failed</span>';
} else if (scan.status === 'cancelled') {
statusBadge = '<span class="badge badge-warning">Cancelled</span>';
} else { } else {
statusBadge = `<span class="badge badge-info">${scan.status}</span>`; 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 // Find previous scan and show compare button
let previousScanId = null; let previousScanId = null;
let currentConfigId = null; let currentConfigId = null;

View File

@@ -26,6 +26,7 @@
<option value="running">Running</option> <option value="running">Running</option>
<option value="completed">Completed</option> <option value="completed">Completed</option>
<option value="failed">Failed</option> <option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select> </select>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
@@ -248,20 +249,27 @@
statusBadge = '<span class="badge badge-info">Running</span>'; statusBadge = '<span class="badge badge-info">Running</span>';
} else if (scan.status === 'failed') { } else if (scan.status === 'failed') {
statusBadge = '<span class="badge badge-danger">Failed</span>'; statusBadge = '<span class="badge badge-danger">Failed</span>';
} else if (scan.status === 'cancelled') {
statusBadge = '<span class="badge badge-warning">Cancelled</span>';
} else { } else {
statusBadge = `<span class="badge badge-info">${scan.status}</span>`; 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 = ` row.innerHTML = `
<td class="mono">${scan.id}</td> <td class="mono">${scan.id}</td>
<td>${scan.title || 'Untitled Scan'}</td> <td>${scan.title || 'Untitled Scan'}</td>
<td class="text-muted">${timestamp}</td> <td class="text-muted">${timestamp}</td>
<td class="mono">${duration}</td> <td class="mono">${duration}</td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td> <td>${actionButtons}</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>
`; `;
tbody.appendChild(row); 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 // Delete scan
async function deleteScan(scanId) { async function deleteScan(scanId) {
if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) { if (!confirm(`Are you sure you want to delete scan ${scanId}?`)) {

View File

@@ -23,7 +23,7 @@ def validate_scan_status(status: str) -> tuple[bool, Optional[str]]:
>>> validate_scan_status('invalid') >>> validate_scan_status('invalid')
(False, 'Invalid status: invalid. Must be one of: running, completed, failed') (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: if status not in valid_statuses:
return False, f'Invalid status: {status}. Must be one of: {", ".join(valid_statuses)}' return False, f'Invalid status: {status}. Must be one of: {", ".join(valid_statuses)}'