diff --git a/app/src/scanner.py b/app/src/scanner.py index 3e7f5eb..1cbffbc 100644 --- a/app/src/scanner.py +++ b/app/src/scanner.py @@ -7,9 +7,11 @@ import argparse import json import logging import os +import signal import subprocess import sys import tempfile +import threading import time import zipfile from datetime import datetime @@ -30,6 +32,11 @@ sys.stdout.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: """Wrapper for masscan to perform network scans based on YAML config or database config""" @@ -63,6 +70,34 @@ class SneakyScanner: 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]: """ Load and validate configuration from file or database. @@ -383,11 +418,31 @@ class SneakyScanner: raise ValueError(f"Invalid protocol: {protocol}") 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) - if result.returncode != 0: - print(f"Masscan stderr: {result.stderr}", file=sys.stderr) + if returncode != 0: + print(f"Masscan stderr: {stderr}", file=sys.stderr) # Parse masscan JSON output results = [] @@ -435,11 +490,31 @@ class SneakyScanner: ] 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) - if result.returncode != 0: - print(f"Masscan stderr: {result.stderr}", file=sys.stderr, flush=True) + if returncode != 0: + print(f"Masscan stderr: {stderr}", file=sys.stderr, flush=True) # Parse results responding_ips = set() @@ -477,6 +552,10 @@ class SneakyScanner: all_services = {} for ip, ports in ip_ports.items(): + # Check if cancelled before each host + if self.is_cancelled(): + break + if not ports: all_services[ip] = [] continue @@ -502,10 +581,29 @@ class SneakyScanner: 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: - print(f" Nmap warning for {ip}: {result.stderr}", file=sys.stderr, flush=True) + stdout, stderr = self._active_process.communicate(timeout=600) + 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 services = self._parse_nmap_xml(xml_output) @@ -894,6 +992,11 @@ class SneakyScanner: progress_callback('ping', None, {'status': 'starting'}) 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 if progress_callback: progress_callback('ping', None, { @@ -907,6 +1010,11 @@ class SneakyScanner: progress_callback('tcp_scan', None, {'status': 'starting'}) 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) 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') @@ -916,6 +1024,11 @@ class SneakyScanner: if progress_callback: progress_callback('udp_scan', None, {'status': 'starting'}) 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: print(f"\n[3/5] Skipping UDP scan (disabled)...", flush=True) if progress_callback: @@ -975,6 +1088,11 @@ class SneakyScanner: ip_ports = {ip: results_by_ip[ip]['actual']['tcp_ports'] for ip in all_ips} 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 for ip, services in service_results.items(): if ip in results_by_ip: diff --git a/app/web/api/scans.py b/app/web/api/scans.py index d4f25f4..1f6a314 100644 --- a/app/web/api/scans.py +++ b/app/web/api/scans.py @@ -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('//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('//status', methods=['GET']) @api_auth_required def get_scan_status(scan_id): diff --git a/app/web/jobs/scan_job.py b/app/web/jobs/scan_job.py index 66d36e5..6b48c21 100644 --- a/app/web/jobs/scan_job.py +++ b/app/web/jobs/scan_job.py @@ -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") diff --git a/app/web/services/scan_service.py b/app/web/services/scan_service.py index c8406bf..c87f273 100644 --- a/app/web/services/scan_service.py +++ b/app/web/services/scan_service.py @@ -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 diff --git a/app/web/templates/scan_detail.html b/app/web/templates/scan_detail.html index 98f9157..2e038c4 100644 --- a/app/web/templates/scan_detail.html +++ b/app/web/templates/scan_detail.html @@ -20,6 +20,10 @@ Refresh + @@ -471,8 +475,11 @@ } else if (scan.status === 'running') { statusBadge = 'Running'; document.getElementById('delete-btn').disabled = true; + document.getElementById('stop-btn').style.display = 'inline-block'; } else if (scan.status === 'failed') { statusBadge = 'Failed'; + } else if (scan.status === 'cancelled') { + statusBadge = 'Cancelled'; } else { statusBadge = `${scan.status}`; } @@ -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; diff --git a/app/web/templates/scans.html b/app/web/templates/scans.html index 3e5ac3b..af07c8f 100644 --- a/app/web/templates/scans.html +++ b/app/web/templates/scans.html @@ -26,6 +26,7 @@ +
@@ -248,20 +249,27 @@ statusBadge = 'Running'; } else if (scan.status === 'failed') { statusBadge = 'Failed'; + } else if (scan.status === 'cancelled') { + statusBadge = 'Cancelled'; } else { statusBadge = `${scan.status}`; } + // Action buttons + let actionButtons = `View`; + if (scan.status === 'running') { + actionButtons += ``; + } else { + actionButtons += ``; + } + row.innerHTML = ` ${scan.id} ${scan.title || 'Untitled Scan'} ${timestamp} ${duration} ${statusBadge} - - View - ${scan.status !== 'running' ? `` : ''} - + ${actionButtons} `; 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}?`)) { diff --git a/app/web/utils/validators.py b/app/web/utils/validators.py index 2123c2a..5c355b2 100644 --- a/app/web/utils/validators.py +++ b/app/web/utils/validators.py @@ -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)}'