diff --git a/Dockerfile.arachni b/Dockerfile.arachni new file mode 100644 index 000000000..74f9553f8 --- /dev/null +++ b/Dockerfile.arachni @@ -0,0 +1,37 @@ +# Use Alpine Linux as base for smaller size +FROM alpine:3.18 + +# Install required packages +RUN apk add --no-cache \ + ruby \ + ruby-dev \ + ruby-bundler \ + build-base \ + libffi-dev \ + openssl-dev \ + sqlite-dev \ + git \ + && rm -rf /var/cache/apk/* + +# Set working directory +WORKDIR /arachni + +# Clone Arachni from source (more recent than Docker Hub images) +RUN git clone https://github.com/Arachni/arachni.git . && \ + git checkout v1.6.1.3 + +# Install Arachni dependencies (simplified approach) +RUN bundle install --without development test + +# Create necessary directories +RUN mkdir -p /arachni/reports /arachni/logs + +# Expose port +EXPOSE 9292 + +# Set environment variables +ENV ARACHNI_UI_ADDRESS=0.0.0.0 +ENV ARACHNI_UI_PORT=9292 + +# Default command - use arachni_web_ui instead of arachni_web +CMD ["bundle", "exec", "arachni_web_ui"] diff --git a/Framework/Built_In_Automation/Security/BuiltInFunctions.py b/Framework/Built_In_Automation/Security/BuiltInFunctions.py index 6d7c7947e..843400286 100644 --- a/Framework/Built_In_Automation/Security/BuiltInFunctions.py +++ b/Framework/Built_In_Automation/Security/BuiltInFunctions.py @@ -53,14 +53,369 @@ def port_scaning_nmap(data_set: list) -> str: return "zeuz_failed" +def check_and_install_wapiti(): + """Check if wapiti is installed; if not, install it automatically (prefers uv/uvx).""" + try: + # If wapiti CLI exists, we're done + if shutil.which("wapiti"): + print("Wapiti is already installed and available in PATH") + return True + + # If uvx/uv exists, we can run wapiti without installing + if shutil.which("uvx") or shutil.which("uv"): + print("Wapiti not installed; will run via uvx/uv without global installation") + return True + + print("Wapiti not found. Attempting automatic installation...") + platform = sys.platform + + # Show what we're going to try + print(f"Platform detected: {platform}") + if platform == "darwin": + print("Will attempt Homebrew installation") + elif platform == "win32": + print("Will attempt pip installation") + else: + print("Will attempt system package manager installation") + + if platform == "darwin": # macOS + print("Detected macOS - attempting multiple installation methods...") + + # Method 1: Try Homebrew (main repository) + print("Trying Homebrew main repository...") + try: + result = subprocess.run(['brew', 'install', 'wapiti'], + capture_output=True, text=True, timeout=300) + if result.returncode == 0: + print("Successfully installed Wapiti via Homebrew") + refresh_path_for_homebrew() + if verify_wapiti_installation(): + return True + else: + print(f"Homebrew main repository failed: {result.stderr}") + except (subprocess.TimeoutExpired, FileNotFoundError): + print("Homebrew not available") + + # Method 2: Try Homebrew with alternative name + print("Trying Homebrew with alternative names...") + alternative_names = ['wapiti3', 'wapiti-scanner'] + for alt_name in alternative_names: + try: + result = subprocess.run(['brew', 'install', alt_name], + capture_output=True, text=True, timeout=300) + if result.returncode == 0: + print(f"Successfully installed {alt_name} via Homebrew") + refresh_path_for_homebrew() + # Check if this creates a wapiti command + if verify_wapiti_installation(): + return True + else: + print(f"{alt_name} installed but wapiti command not found") + except Exception: + continue + + # Method 3: Try pip3 installation (may fail on PyPI) + print("Trying pip3 installation...") + try: + result = subprocess.run([sys.executable, '-m', 'pip', 'install', 'wapiti'], + capture_output=True, text=True, timeout=300) + if result.returncode == 0: + print("Successfully installed Wapiti via pip3") + if verify_wapiti_installation(): + return True + else: + print(f"Pip3 installation failed: {result.stderr}") + except Exception as e: + print(f"Pip3 installation error: {e}") + + # Method 4: Try pip3 with alternative names + print("Trying pip3 with alternative names...") + pip_alternatives = ['wapiti3', 'wapiti-scanner', 'python-wapiti'] + for alt_name in pip_alternatives: + try: + result = subprocess.run([sys.executable, '-m', 'pip', 'install', alt_name], + capture_output=True, text=True, timeout=300) + if result.returncode == 0: + print(f"Successfully installed {alt_name} via pip3") + if verify_wapiti_installation(): + return True + except Exception: + continue + + print("All automatic installation methods failed") + + # Last resort: Try installing from source + print("Attempting installation from source...") + + # Check if git is available + if shutil.which("git"): + if try_install_wapiti_from_source(): + return True + else: + print("Git not available - attempting to install git first...") + try: + result = subprocess.run(['brew', 'install', 'git'], + capture_output=True, text=True, timeout=300) + if result.returncode == 0: + print("Git installed successfully. Now trying source installation...") + refresh_path_for_homebrew() + if try_install_wapiti_from_source(): + return True + else: + print("Failed to install git") + except Exception as e: + print(f"Error installing git: {e}") + + print("Please install git manually: brew install git") + + return False + + elif platform == "win32": # Windows + print("Detected Windows - attempting pip installation...") + try: + result = subprocess.run([sys.executable, '-m', 'pip', 'install', 'wapiti'], + capture_output=True, text=True, timeout=300) + if result.returncode == 0: + print("Successfully installed Wapiti via pip") + # Verify installation + if verify_wapiti_installation(): + return True + else: + print("Installation succeeded but wapiti command not found in PATH") + return False + else: + print(f"Pip installation failed: {result.stderr}") + return False + except subprocess.TimeoutExpired: + print("Pip installation timed out") + return False + + else: # Linux + print("Detected Linux - attempting package manager installation...") + # Try different package managers + package_managers = [ + (['apt-get', 'update', '&&', 'apt-get', 'install', '-y', 'wapiti'], 'apt'), + (['yum', 'install', '-y', 'wapiti'], 'yum'), + (['dnf', 'install', '-y', 'wapiti'], 'dnf'), + (['zypper', 'install', '-y', 'wapiti'], 'zypper'), + ] + + for cmd, manager in package_managers: + try: + print(f"Trying {manager}...") + if '&&' in cmd: + # Handle commands with && operator + result = subprocess.run(' '.join(cmd), shell=True, capture_output=True, text=True, timeout=300) + else: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + + if result.returncode == 0: + print(f"Successfully installed Wapiti via {manager}") + # Verify installation + if verify_wapiti_installation(): + return True + else: + print("Installation succeeded but wapiti command not found in PATH") + return False + except (subprocess.TimeoutExpired, FileNotFoundError): + print("No package manager found or installation failed") + continue + return False + + except Exception as e: + print(f"Error during Wapiti installation: {e}") + return False + + +def show_manual_wapiti_instructions(): + """Show manual installation instructions for different platforms.""" + platform = sys.platform + + if platform == "darwin": # macOS + instructions = [ + ["Platform", "macOS"], + ["Method 1", "uvx --from git+https://github.com/wapiti-scanner/wapiti wapiti --version"], + ["Method 2", "Pip: pip3 install wapiti OR pip3 install wapiti3"], + ["Method 3", "Git: git clone https://github.com/wapiti-scanner/wapiti.git && pip install -e ./wapiti"], + ["Method 4", "Download: https://github.com/wapiti-scanner/wapiti/releases"], + ["Note", "uvx is recommended if you use uv"] + ] + elif platform == "win32": # Windows + instructions = [ + ["Platform", "Windows"], + ["Method 1", "Pip: pip install wapiti"], + ["Method 2", "Download from: https://wapiti.sourceforge.io/"], + ["Note", "Pip installation is recommended for Windows"] + ] + else: # Linux + instructions = [ + ["Platform", "Linux"], + ["Ubuntu/Debian", "sudo apt-get install wapiti"], + ["CentOS/RHEL", "sudo yum install wapiti"], + ["Fedora", "sudo dnf install wapiti"], + ["OpenSUSE", "sudo zypper install wapiti"], + ["Note", "Use your system's package manager"] + ] + + print("\n" + "="*60) + print("MANUAL WAPITI INSTALLATION INSTRUCTIONS") + print("="*60) + for instruction in instructions: + print(f"{instruction[0]}: {instruction[1]}") + print("="*60) + +def refresh_path_for_homebrew(): + """Refresh PATH to include Homebrew binaries.""" + try: + # Common Homebrew paths + homebrew_paths = [ + "/opt/homebrew/bin", # Apple Silicon + "/usr/local/bin", # Intel Mac + "/opt/homebrew/sbin", # Apple Silicon sbin + "/usr/local/sbin", # Intel Mac sbin + ] + + current_path = os.environ.get('PATH', '') + new_paths = [] + + for path in homebrew_paths: + if Path(path).exists() and path not in current_path: + new_paths.append(path) + + if new_paths: + os.environ['PATH'] = f"{':'.join(new_paths)}:{current_path}" + print(f"Added to PATH: {new_paths}") + else: + print("No new Homebrew paths to add") + + except Exception as e: + print(f"Error refreshing PATH: {e}") + +def try_install_wapiti_from_source(): + """Try to install wapiti from source as a last resort.""" + try: + print("Cloning wapiti from GitHub...") + + # Create a temporary directory for the source + import tempfile + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Clone the repository + clone_cmd = ['git', 'clone', 'https://github.com/wapiti-scanner/wapiti.git', str(temp_path)] + result = subprocess.run(clone_cmd, capture_output=True, text=True, timeout=300) + + if result.returncode != 0: + print(f"Git clone failed: {result.stderr}") + return False + + print("Repository cloned successfully. Installing...") + + # Change to the wapiti directory + wapiti_dir = temp_path / "wapiti" + if not wapiti_dir.exists(): + print("Wapiti directory not found after clone") + return False + + # Install using pip + install_cmd = [sys.executable, '-m', 'pip', 'install', '-e', str(wapiti_dir)] + result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=600) + + if result.returncode == 0: + print("Successfully installed wapiti from source") + if verify_wapiti_installation(): + return True + else: + print("Installation succeeded but wapiti command not found") + return False + else: + print(f"Source installation failed: {result.stderr}") + return False + + except Exception as e: + print(f"Error during source installation: {e}") + return False + +def verify_wapiti_installation(): + """Verify that wapiti is properly available (binary or via uvx).""" + try: + # Direct binary available + if shutil.which("wapiti"): + print("✅ Wapiti found in PATH") + try: + result = subprocess.run(['wapiti', '--version'], + capture_output=True, text=True, timeout=10) + if result.returncode == 0: + print(f"✅ Wapiti version: {result.stdout.strip()}") + return True + except subprocess.TimeoutExpired: + print("⚠️ Wapiti version check timed out") + return False + + # Try uvx fallback (ephemeral run) + uvx_cmd = None + if shutil.which("uvx"): + uvx_cmd = [ + "uvx", "--from", "git+https://github.com/wapiti-scanner/wapiti", + "--with", "greenlet", + "wapiti", "--version", + ] + elif shutil.which("uv"): + uvx_cmd = [ + "uv", "tool", "run", "--from", "git+https://github.com/wapiti-scanner/wapiti", + "--with", "greenlet", + "wapiti", "--version", + ] + + if uvx_cmd: + try: + result = subprocess.run(uvx_cmd, capture_output=True, text=True, timeout=60) + if result.returncode == 0: + print(f"✅ Wapiti (via uvx) version: {result.stdout.strip()}") + return True + else: + print(f"⚠️ uvx wapiti check failed: {result.stderr}") + except Exception as e: + print(f"⚠️ uvx wapiti check error: {e}") + + print("❌ Wapiti not available (binary or uvx)") + return False + except Exception as e: + print(f"❌ Error verifying wapiti availability: {e}") + return False + +def build_wapiti_command(wapiti_action: str, target: str) -> list: + """Build the command to run Wapiti, preferring uvx if binary is unavailable.""" + if shutil.which("wapiti"): + return ["wapiti", f"-v={wapiti_action}", "-u", target] + if shutil.which("uvx"): + return [ + "uvx", "--from", "git+https://github.com/wapiti-scanner/wapiti", + "--with", "greenlet", + "wapiti", f"-v={wapiti_action}", "-u", target, + ] + if shutil.which("uv"): + return [ + "uv", "tool", "run", "--from", "git+https://github.com/wapiti-scanner/wapiti", + "--with", "greenlet", + "wapiti", f"-v={wapiti_action}", "-u", target, + ] + # Fallback to expected binary name; subprocess will raise if not found + return ["wapiti", f"-v={wapiti_action}", "-u", target] + def server_scaning_wapiti(data_set: list) -> str: + # Ensure availability (binary or uvx/uv) + if not check_and_install_wapiti(): + print("Failed to make Wapiti available. Please install it manually or ensure uv/uvx is installed.") + return "zeuz_failed" + target = next(item[2] for item in data_set if item[0] == 'wapiti') wapiti_action = next(item[2] for item in data_set if item[0] == 'verbosity') if not target.startswith(("http://", "https://")): target = "http://" + target - command = ["wapiti", f"-v={wapiti_action}", "-u", target] + command = build_wapiti_command(wapiti_action, target) # Set UTF-8 encoding env = os.environ.copy() @@ -79,7 +434,7 @@ def server_scaning_wapiti(data_set: list) -> str: print("Report path not found in Wapiti output.") return "zeuz_failed" - security_report_dir = Path(ConfigModule.get_config_value("sectionOne", "test_case_folder", temp_config)) / 'security_report' / 'wapiti' + security_report_dir = Path(ConfigModule.get_config_value("sectionOne", "test_case_folder", temp_config)) / 'security_report' os.makedirs(security_report_dir, exist_ok=True) destination_path = security_report_dir / os.path.basename(report_path) @@ -89,7 +444,14 @@ def server_scaning_wapiti(data_set: list) -> str: return "passed" except subprocess.CalledProcessError as e: print("An error occurred while running wapiti:") - print(e.stderr) + print(f"Error code: {e.returncode}") + print(f"Error output: {e.stderr}") + return "zeuz_failed" + except FileNotFoundError: + print("Wapiti command not found. This should not happen after automatic availability checks.") + return "zeuz_failed" + except Exception as e: + print(f"Unexpected error running wapiti: {e}") return "zeuz_failed" @@ -99,9 +461,13 @@ def server_scaning_arachni(data_set: list) -> str: if success: if not arachni_target.startswith(("http://", "https://")): arachni_target = "http://" + arachni_target - run_arachni_scan(arachni_target) + + # Get the security report directory for this test case security_report_dir = Path(ConfigModule.get_config_value("sectionOne", "test_case_folder", temp_config)) / 'security_report' - generate_report_from_afr(security_report_dir) + + # Run the scan and save reports directly to security_report_dir + run_arachni_scan(arachni_target, security_report_dir) + return "passed" else: print("***** Arachni setup failed. *****") diff --git a/Framework/Built_In_Automation/Security/arachni_download.py b/Framework/Built_In_Automation/Security/arachni_download.py index 597e2ba19..5c278873d 100644 --- a/Framework/Built_In_Automation/Security/arachni_download.py +++ b/Framework/Built_In_Automation/Security/arachni_download.py @@ -1,6 +1,8 @@ import requests import tarfile import shutil +import subprocess +import sys from tqdm import tqdm from pathlib import Path @@ -9,6 +11,54 @@ ARACHNI_DIR = BASE_DIR / "tools" / "security" / "arachni" RELEASES_API = "https://api.github.com/repos/Arachni/arachni/releases/latest" +def check_docker_available(): + """Check if Docker is available and running.""" + try: + result = subprocess.run(['docker', '--version'], + capture_output=True, text=True, timeout=10) + if result.returncode == 0: + # Check if Docker daemon is running + result = subprocess.run(['docker', 'info'], + capture_output=True, text=True, timeout=10) + return result.returncode == 0 + return False + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + +def start_arachni_container(): + """Start the Arachni Docker container.""" + try: + print("Starting Arachni Docker container...") + + # Check if container is already running + result = subprocess.run(['docker', 'ps', '--filter', 'name=zeuz_arachni', '--format', '{{.Names}}'], + capture_output=True, text=True, timeout=10) + + if 'zeuz_arachni' in result.stdout: + print("Arachni container is already running") + return True + + # Start the container using docker-compose + compose_file = BASE_DIR / "docker-compose.yml" + if not compose_file.exists(): + print("Docker Compose file not found. Please ensure docker-compose.yml exists in project root.") + return False + + result = subprocess.run(['docker-compose', 'up', '-d'], + cwd=BASE_DIR, + capture_output=True, text=True, timeout=120) + + if result.returncode == 0: + print("Arachni container started successfully") + return True + else: + print(f"Failed to start container: {result.stderr}") + return False + + except Exception as e: + print(f"Error starting Arachni container: {e}") + return False + def download_file_with_progress(url, destination): """Download a file with a progress bar.""" try: @@ -56,32 +106,19 @@ def get_latest_release_url(): def check_and_install_arachni(): - """Check if Arachni is installed; if not, download and install the latest version.""" - try: - if ARACHNI_DIR.exists(): - print(f"Arachni is already installed at {ARACHNI_DIR}") + """Check if Arachni is available via Docker; if not, start the container.""" + if check_docker_available(): + print("Docker detected. Using Arachni container...") + + # Start the container if not running + if start_arachni_container(): + print("Arachni is ready via Docker container") return True - - print("Arachni not found. Proceeding with download and installation...") - download_url = get_latest_release_url() - if not download_url: - print("Failed to fetch the download URL.") - return False - print(f"Download URL: {download_url}") - download_path = Path("/tmp/arachni.tar.gz") - if not download_file_with_progress(download_url, download_path): - print("Failed to download Arachni.") + else: + print("Failed to start Arachni container") return False - print("Extracting Arachni...") - with tarfile.open(download_path, "r:gz") as tar: - tar.extractall(path=Path("/tmp")) - extracted_folder = next(name for name in Path("/tmp").iterdir() if name.name.startswith("arachni")) - shutil.move(str(extracted_folder), ARACHNI_DIR) - print(f"Arachni installed to {ARACHNI_DIR}") - download_path.unlink() - print("Installation complete.") - return True - except Exception as e: - print(f"Error during installation: {e}") + else: + print("Docker not available. Please install Docker first.") + print("Download from: https://www.docker.com/products/docker-desktop") return False \ No newline at end of file diff --git a/Framework/Built_In_Automation/Security/arachni_run.py b/Framework/Built_In_Automation/Security/arachni_run.py index 69eadab59..38d52350c 100644 --- a/Framework/Built_In_Automation/Security/arachni_run.py +++ b/Framework/Built_In_Automation/Security/arachni_run.py @@ -1,6 +1,8 @@ import shutil import subprocess +import os +import zipfile from pathlib import Path CURRENT_DIR = Path(__file__).resolve().parent @@ -11,33 +13,100 @@ ARACHNI_ZIP_DIR = BASE_DIR / "Framework" OUTPUT_FILE = ARACHNI_ZIP_DIR / "output.afr" +# Docker paths - corrected for original arachni/arachni image +DOCKER_ARACHNI_BIN = "/usr/local/arachni/bin" +DOCKER_REPORTS_PATH = "/home/arachni/arachni-ui/reports" -def run_arachni_scan(target_url: str): - """Run an Arachni scan for the target website.""" - if not ARACHNI_EXECUTABLE.exists(): - print("Arachni is not installed or the path is incorrect.") +def check_docker_available(): + """Check if Docker is available and running.""" + try: + result = subprocess.run(['docker', '--version'], + capture_output=True, text=True, timeout=10) + if result.returncode == 0: + # Check if Docker daemon is running + result = subprocess.run(['docker', 'info'], + capture_output=True, text=True, timeout=10) + return result.returncode == 0 + return False + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + +def run_arachni_scan(target_url: str, security_report_dir: Path = None): + """Run an Arachni scan using Docker container and save reports to security_report_dir.""" + if not check_docker_available(): + print("Docker not available. Cannot run Arachni scan.") return - print(f"Running Arachni scan on {target_url}...") - subprocess.run([str(ARACHNI_EXECUTABLE), target_url, "--report-save-path", str(OUTPUT_FILE)], check=True) - print(f"Scan complete. Results saved to {OUTPUT_FILE}.") + + print(f"Running Arachni scan on {target_url} via Docker...") + + # Use provided security_report_dir or create a temporary one + if security_report_dir is None: + security_report_dir = Path.cwd() / "temp_reports" + + # Ensure reports directory exists + security_report_dir.mkdir(parents=True, exist_ok=True) + + try: + # First, ensure the reports directory exists inside the Docker container + print("Ensuring reports directory exists in Docker container...") + mkdir_cmd = [ + 'docker', 'exec', 'zeuz_arachni', + 'mkdir', '-p', '/home/arachni/arachni-ui/reports' + ] + subprocess.run(mkdir_cmd, capture_output=True, text=True) # Remove check=True to handle existing dir + print("Reports directory created/verified in Docker container") + + # Run scan using Docker container with correct paths + scan_cmd = [ + 'docker', 'exec', 'zeuz_arachni', + f'{DOCKER_ARACHNI_BIN}/arachni', + target_url, + '--report-save-path', f'{DOCKER_REPORTS_PATH}/output.afr' + ] + + print(f"Executing scan command: {' '.join(scan_cmd)}") + result = subprocess.run(scan_cmd, capture_output=True, text=True, check=True) + print("Scan completed successfully") + print(f"Scan output: {result.stdout}") + + # Generate HTML report - specify output directory to avoid saving to root + print("Generating HTML report...") + report_cmd = [ + 'docker', 'exec', 'zeuz_arachni', + f'{DOCKER_ARACHNI_BIN}/arachni_reporter', + f'{DOCKER_REPORTS_PATH}/output.afr', + '--reporter=html:outfile=/home/arachni/arachni-ui/reports/arachni_report.html.zip' + ] + + subprocess.run(report_cmd, capture_output=True, text=True, check=True) + print("HTML report generated successfully") + + # Copy reports from Docker container to security_report_dir + print("Copying reports to security report directory...") + copy_afr_cmd = [ + 'docker', 'cp', 'zeuz_arachni:/home/arachni/arachni-ui/reports/output.afr', + str(security_report_dir / 'arachni_output.afr') + ] + subprocess.run(copy_afr_cmd, capture_output=True, text=True, check=True) + + copy_html_cmd = [ + 'docker', 'cp', 'zeuz_arachni:/home/arachni/arachni-ui/reports/arachni_report.html.zip', + str(security_report_dir / 'arachni_report.html.zip') + ] + subprocess.run(copy_html_cmd, capture_output=True, text=True, check=True) + + print(f"Reports copied to: {security_report_dir}") + + except subprocess.CalledProcessError as e: + print(f"Arachni scan failed: {e}") + print(f"Error output: {e.stderr}") + raise + except Exception as e: + print(f"Unexpected error during Arachni scan: {e}") + raise def generate_report_from_afr(security_report_dir: Path): - """Generate a report from the existing .afr file and delete the .afr file afterward.""" - if not OUTPUT_FILE.exists(): - print(f"{OUTPUT_FILE} not found.") - return - print(f"Generating HTML report from {OUTPUT_FILE}...") - security_report_dir.mkdir(parents=True, exist_ok=True) - subprocess.run([str(ARACHNI_REPORTER_EXECUTABLE), str(OUTPUT_FILE), "--reporter=html:"], check=True) - zip_file = next((f for f in OUTPUT_FILE.parent.glob("*.html.zip")), None) - if zip_file: - generated_zip = zip_file - destination = security_report_dir / zip_file.name - shutil.move(str(generated_zip), str(destination)) - print(f"Moved {generated_zip} to {destination}") - print(f"Deleting {OUTPUT_FILE}...") - OUTPUT_FILE.unlink() - print(f"{OUTPUT_FILE} deleted.") - else: - return False + """This function is deprecated. Reports are now saved directly during scan execution.""" + print("Note: generate_report_from_afr is deprecated. Reports are now saved directly during scan execution.") + return True diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..4f8034077 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3.8' +services: + arachni: + image: arachni/arachni:latest + container_name: zeuz_arachni + ports: + - "9292:9292" # Web UI port + environment: + - ARACHNI_UI_ADDRESS=0.0.0.0 + - ARACHNI_UI_PORT=9292 + restart: unless-stopped + # Increase memory limits to prevent crashes + deploy: + resources: + limits: + memory: 2G + reservations: + memory: 1G