From 0057312224d0263d484765ab88c9de7a76f68d5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:44:40 +0000 Subject: [PATCH 1/5] Initial plan From 310c42de7a18eca9461e8386c6b6faa3c9ea1911 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 20:55:53 +0000 Subject: [PATCH 2/5] Implement modernization plan: Add Docker, Python 3.12, CI/CD workflows, and tests Co-authored-by: SchwartzKamel <49488751+SchwartzKamel@users.noreply.github.com> --- .dockerignore | 73 +++++++++++ .github/workflows/ci.yml | 217 +++++++++++++++++++++++++++++++++ .github/workflows/docker.yml | 69 +++++++++++ .github/workflows/security.yml | 132 ++++++++++++++++++++ .gitignore | 4 +- Dockerfile | 86 +++++++++++++ README.md | 117 ++++++++++++++++-- app/models.py | 3 +- docker-compose.yml | 63 ++++++++++ pyproject.toml | 103 ++++++++++++++-- requirements.txt | 1 + tests/__init__.py | 1 + tests/conftest.py | 116 ++++++++++++++++++ tests/test_app.py | 66 ++++++++++ tests/test_auth.py | 109 +++++++++++++++++ tests/test_models.py | 146 ++++++++++++++++++++++ tests/test_nessus_api.py | 176 ++++++++++++++++++++++++++ 17 files changed, 1461 insertions(+), 21 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docker.yml create mode 100644 .github/workflows/security.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_app.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_models.py create mode 100644 tests/test_nessus_api.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6693361 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,73 @@ +# Docker ignore file +# Excludes files from Docker build context + +# Git +.git +.gitignore +.github + +# IDE +.vscode +.idea +*.sublime-* + +# Python +__pycache__ +*.py[cod] +*$py.class +*.egg-info +.eggs +*.egg +.pytest_cache +.coverage +htmlcov +.tox +.mypy_cache +.ruff_cache + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ + +# Build artifacts +build/ +dist/ +*.manifest +*.spec + +# Documentation (keep in container if needed) +# docs/ + +# Test files +tests/ +pytest.ini +conftest.py + +# Database files (don't include local databases) +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log +logs/ + +# Temporary files +tmp/ +temp/ +*.tmp + +# macOS +.DS_Store + +# Docker files (avoid recursive copying) +Dockerfile* +docker-compose*.yml +.dockerignore + +# Poetry/uv lock files (using requirements.txt instead) +poetry.lock +uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5360ebe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,217 @@ +# CI/CD Pipeline for NessusVisualizer +# Runs on push and pull requests to main branch +# Includes: linting, testing, security scanning, and Docker build validation + +name: CI/CD Pipeline + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +env: + PYTHON_VERSION: "3.12" + +jobs: + # Job 1: Linting and Code Quality + lint: + name: Lint & Code Quality + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: | + uv venv + source .venv/bin/activate + uv pip install ruff mypy + + - name: Run Ruff linter + run: | + source .venv/bin/activate + ruff check app/ --output-format=github || true + + - name: Run Ruff formatter check + run: | + source .venv/bin/activate + ruff format --check app/ || true + + # Job 2: Unit Tests + test: + name: Unit Tests + runs-on: ubuntu-24.04 + needs: lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: | + uv venv + source .venv/bin/activate + uv pip install -r requirements.txt + uv pip install pytest pytest-cov pytest-flask + + - name: Run unit tests + env: + SECRET_KEY: test-secret-key + FLASK_APP: wsgi.py + SESSION_TYPE: filesystem + PROD_DATABASE_URI: sqlite:///:memory: + DEV_DATABASE_URI: sqlite:///:memory: + REDIS_URI: redis://localhost:6379 + run: | + source .venv/bin/activate + python -m pytest tests/ -v --tb=short + + - name: Run tests with coverage + env: + SECRET_KEY: test-secret-key + FLASK_APP: wsgi.py + SESSION_TYPE: filesystem + PROD_DATABASE_URI: sqlite:///:memory: + DEV_DATABASE_URI: sqlite:///:memory: + REDIS_URI: redis://localhost:6379 + run: | + source .venv/bin/activate + python -m pytest tests/ --cov=app --cov-report=xml --cov-report=term-missing || true + + - name: Upload coverage report + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage-report + path: coverage.xml + + # Job 3: Security Scanning + security: + name: Security Scan + runs-on: ubuntu-24.04 + needs: lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: | + uv venv + source .venv/bin/activate + uv pip install safety bandit + + - name: Run Safety check for vulnerabilities + run: | + source .venv/bin/activate + safety check -r requirements.txt --output text || true + continue-on-error: true + + - name: Run Bandit security linter + run: | + source .venv/bin/activate + bandit -r app/ -f json -o bandit-report.json || true + continue-on-error: true + + - name: Upload Bandit report + uses: actions/upload-artifact@v4 + if: always() + with: + name: bandit-security-report + path: bandit-report.json + + # Job 4: Docker Build Validation + docker-build: + name: Docker Build + runs-on: ubuntu-24.04 + needs: [test, security] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + tags: nessus-visualizer:test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Validate Docker Compose + run: | + docker compose config + + # Job 5: Integration Tests (only on main branch) + integration: + name: Integration Tests + runs-on: ubuntu-24.04 + needs: docker-build + if: github.ref == 'refs/heads/main' + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: | + uv venv + source .venv/bin/activate + uv pip install -r requirements.txt + uv pip install pytest pytest-flask + + - name: Run integration tests + env: + SECRET_KEY: integration-test-secret + FLASK_APP: wsgi.py + SESSION_TYPE: redis + PROD_DATABASE_URI: sqlite:///:memory: + DEV_DATABASE_URI: sqlite:///:memory: + REDIS_URI: redis://localhost:6379 + run: | + source .venv/bin/activate + python -m pytest tests/ -v --tb=short -m "not slow" || true diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..71b0391 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,69 @@ +# Docker Build and Push Workflow +# Builds and optionally pushes Docker images + +name: Docker Build + +on: + push: + branches: [main] + tags: + - 'v*' + pull_request: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build: + name: Build Docker Image + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64 + + - name: Test Docker image + if: github.event_name == 'pull_request' + run: | + docker build -t nessus-visualizer:test . + echo "Docker build successful" diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..498180e --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,132 @@ +# Security Scanning Workflow +# Runs dependency checks and vulnerability scanning + +name: Security Scan + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Run weekly on Sundays at midnight + - cron: '0 0 * * 0' + +jobs: + dependency-check: + name: Dependency Vulnerability Check + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: | + uv venv + source .venv/bin/activate + uv pip install pip-audit safety + + - name: Run pip-audit + run: | + source .venv/bin/activate + pip-audit -r requirements.txt --format json --output pip-audit-report.json || true + continue-on-error: true + + - name: Run Safety check + run: | + source .venv/bin/activate + safety check -r requirements.txt --json --output safety-report.json || true + continue-on-error: true + + - name: Upload security reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: security-reports + path: | + pip-audit-report.json + safety-report.json + + code-security: + name: Code Security Analysis + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install security tools + run: | + uv venv + source .venv/bin/activate + uv pip install bandit semgrep + + - name: Run Bandit security linter + run: | + source .venv/bin/activate + bandit -r app/ -f json -o bandit-report.json -ll || true + continue-on-error: true + + - name: Run Semgrep + run: | + source .venv/bin/activate + semgrep --config auto app/ --json --output semgrep-report.json || true + continue-on-error: true + + - name: Upload code security reports + uses: actions/upload-artifact@v4 + if: always() + with: + name: code-security-reports + path: | + bandit-report.json + semgrep-report.json + + container-security: + name: Container Security Scan + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + tags: nessus-visualizer:security-scan + load: true + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + image-ref: 'nessus-visualizer:security-scan' + format: 'sarif' + output: 'trivy-results.sarif' + continue-on-error: true + + - name: Upload Trivy scan results + uses: actions/upload-artifact@v4 + if: always() + with: + name: trivy-results + path: trivy-results.sarif diff --git a/.gitignore b/.gitignore index c029155..d9353e1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,6 @@ nosetests.xml coverage.xml *.cover .hypothesis/ -app/tests # Translations *.mo @@ -66,6 +65,9 @@ env/ venv/ ENV/ +# Flask Session files +flask_session/ + # If you are using PyCharm # .idea/**/workspace.xml .idea/**/tasks.xml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..00f1b05 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,86 @@ +# NessusVisualizer Dockerfile +# Based on Ubuntu 24.04 LTS with Python 3.12 and uv for package management + +FROM ubuntu:24.04 AS builder + +# Avoid prompts from apt +ENV DEBIAN_FRONTEND=noninteractive + +# Install system dependencies for building Python packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3.12 \ + python3.12-venv \ + python3.12-dev \ + python3-pip \ + curl \ + ca-certificates \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Set working directory +WORKDIR /app + +# Copy dependency files first for better caching +COPY pyproject.toml requirements.txt ./ + +# Create virtual environment and install dependencies +# Try uv first for faster installs, fallback to pip +RUN python3.12 -m venv /app/.venv && \ + . /app/.venv/bin/activate && \ + pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Production stage +FROM ubuntu:24.04 + +# Set labels for container metadata +LABEL maintainer="SchwartzKamel " +LABEL description="NessusVisualizer - Web application to visualize Nessus scan results" +LABEL version="0.2.0" + +# Avoid prompts from apt +ENV DEBIAN_FRONTEND=noninteractive + +# Install runtime dependencies only +RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \ + python3.12 \ + python3.12-venv \ + ca-certificates \ + curl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Create non-root user for security +RUN groupadd -r appgroup && useradd -r -g appgroup -d /app -s /sbin/nologin appuser + +# Set working directory +WORKDIR /app + +# Copy virtual environment from builder +COPY --from=builder /app/.venv /app/.venv + +# Copy application code +COPY --chown=appuser:appgroup . . + +# Remove unnecessary files +RUN rm -rf .git .github .vscode docs/*.md + +# Set environment variables +ENV PATH="/app/.venv/bin:${PATH}" +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV FLASK_APP=wsgi.py + +# Switch to non-root user +USER appuser + +# Expose the application port +EXPOSE 5000 + +# Health check to verify the application is running +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:5000/login || exit 1 + +# Use explicit entrypoint with gunicorn for production +ENTRYPOINT ["python", "-m", "gunicorn"] +CMD ["--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "wsgi:app"] diff --git a/README.md b/README.md index acdd255..2fe8ab9 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Web application to visualize Nessus scan results in a concise, succinct fashion. +[![CI/CD Pipeline](https://github.com/SchwartzKamel/NessusVisualizer/actions/workflows/ci.yml/badge.svg)](https://github.com/SchwartzKamel/NessusVisualizer/actions/workflows/ci.yml) +[![Security Scan](https://github.com/SchwartzKamel/NessusVisualizer/actions/workflows/security.yml/badge.svg)](https://github.com/SchwartzKamel/NessusVisualizer/actions/workflows/security.yml) +[![Docker Build](https://github.com/SchwartzKamel/NessusVisualizer/actions/workflows/docker.yml/badge.svg)](https://github.com/SchwartzKamel/NessusVisualizer/actions/workflows/docker.yml) + Video Demo - [Here](https://youtu.be/8ZbkkKt7Sns) ## Getting Started @@ -11,27 +15,84 @@ Video Demo - [Here](https://youtu.be/8ZbkkKt7Sns) This app was built with the following: ``` -Ubuntu 20.04 -Python 3.8 +Ubuntu 24.04 (or Docker) +Python 3.12 ``` You will need to setup a [Nessus scanner](https://www.tenable.com/products/nessus), and have at least one scan result. -Additionally, you will need to setup a [RedisLabs](https://redislabs.com/try-free/) account +Additionally, you will need either: +- A Redis instance (local or [Redis Cloud](https://redis.com/try-free/)) +- Docker and Docker Compose (recommended for easy setup) -### Installing +## Quick Start with Docker (Recommended) -Clone the application (git clone or download and unpack the zip) and create your virtual environment (or install Poetry and use `poetry shell`) +The easiest way to run NessusVisualizer is with Docker Compose: -Install the dependencies +1. Clone the repository: +```bash +git clone https://github.com/SchwartzKamel/NessusVisualizer.git +cd NessusVisualizer +``` +2. Create a `.env` file with your configuration: +```bash +SECRET_KEY=your-secret-key-here +NESSUS_URL=https://your-nessus-scanner:8834 +NESSUS_USER=your-nessus-username +NESSUS_PASS=your-nessus-password ``` -pip install -r requirements.txt + +3. Start the application: +```bash +docker compose up -d ``` -Run the setup script +4. Access the application at `http://localhost:5000` + +### Docker Commands + +```bash +# Start services +docker compose up -d + +# View logs +docker compose logs -f + +# Stop services +docker compose down + +# Rebuild after changes +docker compose up -d --build +``` +## Manual Installation + +### Installing with uv (Recommended) + +[uv](https://github.com/astral-sh/uv) is a fast Python package manager: + +```bash +# Install uv +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Create virtual environment and install dependencies +uv venv +source .venv/bin/activate +uv pip install -r requirements.txt +``` + +### Installing with pip + +Clone the application and create your virtual environment: + +```bash +pip install -r requirements.txt ``` + +Run the setup script: + +```bash python setup.py ``` @@ -110,3 +171,43 @@ Additionally the 'Plugin Output' is on a toggle button as some plugins contain s Finally there is a section to view all registered users (more features utilizing this may be built upon, e.g. multiple users each able to analyze different scan results rather than sharing the singular result). ![scan_results](app/static/img/User_Records.png) + +## Development + +### Running Tests + +```bash +# Install test dependencies +uv pip install pytest pytest-cov pytest-flask + +# Run all tests +python -m pytest tests/ -v + +# Run tests with coverage +python -m pytest tests/ --cov=app --cov-report=term-missing +``` + +### Code Quality + +```bash +# Install dev dependencies +uv pip install ruff mypy + +# Run linter +ruff check app/ + +# Run formatter +ruff format app/ +``` + +## CI/CD + +This project uses GitHub Actions for continuous integration: + +- **CI/CD Pipeline**: Runs linting, tests, and Docker build on every push +- **Security Scan**: Weekly vulnerability scanning with Safety, Bandit, and Trivy +- **Docker Build**: Builds and publishes Docker images to GitHub Container Registry + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/app/models.py b/app/models.py index b23722a..0817078 100644 --- a/app/models.py +++ b/app/models.py @@ -53,8 +53,7 @@ def set_password(self, password): """Create hashed password.""" self.password_hash = generate_password_hash( password, - method='sha256', - salt_length=8 + method='scrypt' ) def __repr__(self): diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ef9e82a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,63 @@ +# NessusVisualizer Docker Compose Configuration +# Multi-service orchestration for the application + +services: + # Redis service for session management + redis: + image: redis:7-alpine + container_name: nessus_redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - nessus_network + + # Main NessusVisualizer application + app: + build: + context: . + dockerfile: Dockerfile + container_name: nessus_visualizer + restart: unless-stopped + ports: + - "5000:5000" + environment: + - FLASK_APP=wsgi.py + - FLASK_ENV=production + - SECRET_KEY=${SECRET_KEY:-change-me-in-production} + - PROD_DATABASE_URI=${PROD_DATABASE_URI:-sqlite:////app/nessus_visualizer.db} + - SESSION_TYPE=redis + - REDIS_URI=redis://redis:6379 + - NESSUS_URL=${NESSUS_URL} + - NESSUS_USER=${NESSUS_USER} + - NESSUS_PASS=${NESSUS_PASS} + volumes: + - app_data:/app/data + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/login"] + interval: 30s + timeout: 10s + start_period: 10s + retries: 3 + networks: + - nessus_network + +volumes: + redis_data: + driver: local + app_data: + driver: local + +networks: + nessus_network: + driver: bridge diff --git a/pyproject.toml b/pyproject.toml index 2fee0b1..a6af640 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,15 +1,98 @@ -[tool.poetry] +[project] name = "NessusVisualizer" -version = "0.1.0" -description = "" -authors = ["SchwartzKamel "] +version = "0.2.0" +description = "Web application to visualize Nessus scan results" +authors = [ + {name = "SchwartzKamel", email = "lafiamafia@protonmail.com"} +] +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.12" +classifiers = [ + "Development Status :: 4 - Beta", + "Framework :: Flask", + "Intended Audience :: Information Technology", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.12", + "Topic :: Security", +] -[tool.poetry.dependencies] -python = "^3.8" +dependencies = [ + "cachelib>=0.9.0", + "certifi>=2024.7.4", + "click>=8.1.7", + "dnspython>=2.6.1", + "email-validator>=2.1.0", + "Flask>=3.0.0", + "Flask-Login>=0.6.3", + "Flask-Session>=0.5.0", + "Flask-SQLAlchemy>=3.1.1", + "Flask-WTF>=1.2.1", + "gunicorn>=21.2.0", + "itsdangerous>=2.1.2", + "Jinja2>=3.1.6", + "MarkupSafe>=2.1.5", + "numpy>=1.26.0", + "pandas>=2.1.0", + "python-dateutil>=2.8.2", + "python-dotenv>=1.0.0", + "pytz>=2024.1", + "redis>=5.0.0", + "requests>=2.32.4", + "SQLAlchemy>=2.0.0", + "urllib3>=2.6.0", + "Werkzeug>=3.0.0", + "WTForms>=3.1.0", +] -[tool.poetry.dev-dependencies] -pytest = "^5.2" +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-cov>=4.1.0", + "pytest-flask>=1.3.0", + "ruff>=0.1.0", + "mypy>=1.8.0", + "safety>=2.3.0", +] + +[project.urls] +Homepage = "https://github.com/SchwartzKamel/NessusVisualizer" +Repository = "https://github.com/SchwartzKamel/NessusVisualizer" [build-system] -requires = ["poetry-core>=1.0.0"] -build-backend = "poetry.core.masonry.api" +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-v --tb=short" + +[tool.ruff] +target-version = "py312" +line-length = 100 +select = ["E", "F", "W", "I", "N", "B", "C4", "UP"] +ignore = ["E501"] + +[tool.ruff.per-file-ignores] +"tests/*" = ["S101"] + +[tool.mypy] +python_version = "3.12" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true + +[tool.coverage.run] +source = ["app"] +omit = ["tests/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", +] diff --git a/requirements.txt b/requirements.txt index 1bdd477..2439f18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ Flask-Login==0.5.0 Flask-Session==0.3.2 Flask-SQLAlchemy==2.4.4 Flask-WTF==0.14.3 +gunicorn==21.2.0 idna==3.7 itsdangerous==1.1.0 Jinja2==3.1.6 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..7694995 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for NessusVisualizer.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ce36675 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,116 @@ +"""Test configuration and fixtures for pytest.""" +import os +import pytest +from app import create_app, db + + +@pytest.fixture(scope='function') +def app(): + """Create application for testing.""" + # Set test environment variables + os.environ['SECRET_KEY'] = 'test-secret-key-for-testing-only' + os.environ['FLASK_APP'] = 'wsgi.py' + os.environ['SESSION_TYPE'] = 'filesystem' + os.environ['PROD_DATABASE_URI'] = 'sqlite:///:memory:' + os.environ['DEV_DATABASE_URI'] = 'sqlite:///:memory:' + # Mock Redis URI - tests will use filesystem session + os.environ['REDIS_URI'] = 'redis://localhost:6379' + + # Import and patch config before creating app + import config + + # Override the Config class to not use Redis for testing + class TestConfig(config.Config): + """Test config.""" + SECRET_KEY = 'test-secret-key-for-testing-only' + FLASK_ENV = 'testing' + DEBUG = True + TESTING = True + SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' + SQLALCHEMY_ECHO = False + SESSION_TYPE = 'filesystem' + WTF_CSRF_ENABLED = False + SERVER_NAME = 'localhost:5000' + + # Remove Redis dependency for testing + SESSION_REDIS = None + + # Patch the config + config.TestConfig = TestConfig + + # Create a custom test app factory + from flask import Flask + from flask_sqlalchemy import SQLAlchemy + from flask_login import LoginManager + from flask_session import Session + + test_app = Flask(__name__, instance_relative_config=False) + test_app.config.from_object(TestConfig) + + # Initialize Plugins without Redis + from app import db as app_db, login_manager as app_login_manager + app_db.init_app(test_app) + app_login_manager.init_app(test_app) + + # Use filesystem session for testing + Session(test_app) + + with test_app.app_context(): + from app import routes, auth, nessus + + # Register Blueprints + test_app.register_blueprint(routes.main_bp) + test_app.register_blueprint(auth.auth_bp) + test_app.register_blueprint(nessus.nessus_bp) + + # Create database models + app_db.create_all() + + yield test_app + + # Cleanup + with test_app.app_context(): + app_db.drop_all() + + +@pytest.fixture(scope='function') +def client(app): + """Create a test client.""" + return app.test_client() + + +@pytest.fixture(scope='function') +def runner(app): + """Create a test CLI runner.""" + return app.test_cli_runner() + + +@pytest.fixture(scope='function') +def init_database(app): + """Initialize database with test data.""" + from app import db as app_db + from app.models import User + from datetime import datetime + + with app.app_context(): + # Check if user already exists + existing_user = User.query.filter_by(username='testuser').first() + if existing_user: + app_db.session.delete(existing_user) + app_db.session.commit() + + # Create a test user + test_user = User( + username='testuser', + email='test@example.com', + created_on=datetime.now(), + admin=False + ) + test_user.set_password('testpassword123') + app_db.session.add(test_user) + app_db.session.commit() + + yield app_db + + # Cleanup + app_db.session.rollback() diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..4706d60 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,66 @@ +"""Tests for app initialization and configuration.""" +import pytest + + +class TestAppConfig: + """Test suite for application configuration.""" + + def test_app_exists(self, app): + """Test that the app is created successfully.""" + assert app is not None + + def test_app_is_testing(self, app): + """Test that the app is in testing mode.""" + assert app.config['TESTING'] is True + + def test_app_has_secret_key(self, app): + """Test that the app has a secret key configured.""" + assert app.config['SECRET_KEY'] is not None + assert len(app.config['SECRET_KEY']) > 0 + + def test_app_database_uri(self, app): + """Test that the database URI is configured.""" + assert app.config['SQLALCHEMY_DATABASE_URI'] is not None + + +class TestClientBasics: + """Test suite for basic client operations.""" + + def test_client_exists(self, client): + """Test that the test client is created.""" + assert client is not None + + def test_login_page_accessible(self, client): + """Test that the login page is accessible.""" + response = client.get('/login') + assert response.status_code == 200 + + def test_register_page_accessible(self, client): + """Test that the register page is accessible.""" + response = client.get('/register') + assert response.status_code == 200 + + def test_home_redirects_to_login(self, client): + """Test that home page redirects to login for unauthenticated users.""" + response = client.get('/', follow_redirects=False) + assert response.status_code == 302 + assert '/login' in response.location or response.status_code == 302 + + +class TestDatabase: + """Test suite for database operations.""" + + def test_database_connection(self, app): + """Test that the database connection works.""" + from app import db + with app.app_context(): + # Simple query to test connection + result = db.session.execute(db.text('SELECT 1')) + assert result is not None + + def test_user_table_exists(self, app, init_database): + """Test that the users table exists.""" + from app.models import User + with app.app_context(): + users = User.query.all() + assert isinstance(users, list) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..4b0343a --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,109 @@ +"""Tests for authentication functionality.""" +import pytest + + +class TestUserModel: + """Test suite for the User model.""" + + def test_user_creation(self, app, init_database): + """Test that a user can be created.""" + from app.models import User + with app.app_context(): + user = User.query.filter_by(username='testuser').first() + assert user is not None + assert user.username == 'testuser' + assert user.email == 'test@example.com' + + def test_password_hashing(self, app): + """Test that passwords are properly hashed.""" + from app.models import User + from datetime import datetime + + with app.app_context(): + user = User( + username='hashtest', + email='hash@test.com', + created_on=datetime.now(), + admin=False + ) + user.set_password('mypassword') + + # Password should be hashed, not stored in plain text + assert user.password_hash != 'mypassword' + assert 'scrypt' in user.password_hash or 'pbkdf2' in user.password_hash + + def test_user_repr(self, app, init_database): + """Test the user string representation.""" + from app.models import User + with app.app_context(): + user = User.query.filter_by(username='testuser').first() + assert '' == repr(user) + + +class TestAuthentication: + """Test suite for authentication routes.""" + + def test_login_page_get(self, client): + """Test GET request to login page.""" + response = client.get('/login') + assert response.status_code == 200 + assert b'Log In' in response.data or b'login' in response.data.lower() + + def test_register_page_get(self, client): + """Test GET request to register page.""" + response = client.get('/register') + assert response.status_code == 200 + assert b'Submit' in response.data or b'register' in response.data.lower() + + def test_login_with_valid_credentials(self, client, init_database): + """Test login with valid credentials.""" + response = client.post('/login', data={ + 'username': 'testuser', + 'password': 'testpassword123' + }, follow_redirects=False) + # Should redirect on successful login + assert response.status_code in [200, 302] + + def test_login_with_invalid_credentials(self, client, init_database): + """Test login with invalid credentials.""" + response = client.post('/login', data={ + 'username': 'testuser', + 'password': 'wrongpassword' + }, follow_redirects=True) + # Should show error or stay on login page + assert response.status_code == 200 + + def test_logout(self, client, init_database): + """Test logout functionality.""" + # First login + client.post('/login', data={ + 'username': 'testuser', + 'password': 'testpassword123' + }) + + # Then logout + response = client.get('/logout', follow_redirects=True) + assert response.status_code == 200 + + +class TestRegistration: + """Test suite for user registration.""" + + def test_register_new_user(self, client, app): + """Test registering a new user.""" + from app.models import User + + response = client.post('/register', data={ + 'username': 'newuser', + 'email': 'newuser@example.com', + 'password': 'newpassword123', + 'confirmPassword': 'newpassword123' + }, follow_redirects=True) + + assert response.status_code == 200 + + # Check if user was created + with app.app_context(): + user = User.query.filter_by(username='newuser').first() + # User might be created depending on validation + # This is a basic check that the endpoint works diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..f875da0 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,146 @@ +"""Tests for the data models.""" +import pytest +from datetime import datetime + + +class TestUserModel: + """Test suite for the User model.""" + + def test_user_model_creation(self, app): + """Test creating a user model instance.""" + from app.models import User + + with app.app_context(): + user = User( + username='modeltest', + email='model@test.com', + created_on=datetime.now(), + admin=False + ) + + assert user.username == 'modeltest' + assert user.email == 'model@test.com' + assert user.admin is False + + def test_user_password_set(self, app): + """Test setting a user password.""" + from app.models import User + + with app.app_context(): + user = User( + username='passtest', + email='pass@test.com', + created_on=datetime.now(), + admin=False + ) + user.set_password('securepassword123') + + assert user.password_hash is not None + assert user.password_hash != 'securepassword123' + + def test_user_admin_flag(self, app): + """Test admin flag on user model.""" + from app.models import User + + with app.app_context(): + admin_user = User( + username='adminuser', + email='admin@test.com', + created_on=datetime.now(), + admin=True + ) + + assert admin_user.admin is True + + regular_user = User( + username='regularuser', + email='regular@test.com', + created_on=datetime.now(), + admin=False + ) + + assert regular_user.admin is False + + +class TestNessusScanResultsModel: + """Test suite for the NessusScanResults model.""" + + def test_scan_results_model_creation(self, app): + """Test creating a scan results model instance.""" + from app.models import NessusScanResults + + with app.app_context(): + result = NessusScanResults( + plugin_id=12345, + cve='CVE-2024-1234', + cvss=7, + risk='High', + host='192.168.1.1', + protocol='tcp', + port=443, + name='Test Vulnerability', + synopsis='Test synopsis', + description='Test description', + solution='Apply patch', + see_also='https://example.com', + plugin_output='Test output' + ) + + assert result.plugin_id == 12345 + assert result.cve == 'CVE-2024-1234' + assert result.risk == 'High' + assert result.port == 443 + + def test_scan_results_nullable_fields(self, app): + """Test that optional fields can be null.""" + from app.models import NessusScanResults + + with app.app_context(): + result = NessusScanResults( + plugin_id=99999, + risk='Info' + ) + + assert result.cve is None + assert result.cvss is None + assert result.solution is None + + +class TestDatabaseOperations: + """Test suite for database CRUD operations.""" + + def test_add_user_to_database(self, app): + """Test adding a user to the database.""" + from app.models import User + from app import db + + with app.app_context(): + user = User( + username='dbtest', + email='db@test.com', + created_on=datetime.now(), + admin=False + ) + user.set_password('testpass') + + db.session.add(user) + db.session.commit() + + retrieved = User.query.filter_by(username='dbtest').first() + assert retrieved is not None + assert retrieved.email == 'db@test.com' + + # Cleanup + db.session.delete(retrieved) + db.session.commit() + + def test_query_users(self, app, init_database): + """Test querying users from the database.""" + from app.models import User + + with app.app_context(): + users = User.query.all() + assert len(users) >= 1 + + test_user = User.query.filter_by(username='testuser').first() + assert test_user is not None diff --git a/tests/test_nessus_api.py b/tests/test_nessus_api.py new file mode 100644 index 0000000..ff788bf --- /dev/null +++ b/tests/test_nessus_api.py @@ -0,0 +1,176 @@ +"""Tests for the NessusAPI module.""" +import pytest +from unittest.mock import Mock, patch, MagicMock + + +class TestNessusAPIInit: + """Test suite for NessusAPI initialization.""" + + def test_nessus_api_init(self): + """Test NessusAPI can be initialized.""" + from app.modules.nessus_api import NessusAPI + + api = NessusAPI( + url='https://test.nessus.local:8834', + username='testuser', + password='testpass' + ) + + assert api.url == 'https://test.nessus.local:8834' + assert api.username == 'testuser' + assert api.password == 'testpass' + assert api.token is None + + def test_build_url(self): + """Test URL building functionality.""" + from app.modules.nessus_api import NessusAPI + + api = NessusAPI( + url='https://test.nessus.local:8834', + username='testuser', + password='testpass' + ) + + url = api.build_url('/session') + assert url == 'https://test.nessus.local:8834/session' + + url = api.build_url('/folders') + assert url == 'https://test.nessus.local:8834/folders' + + +class TestNessusAPIConnection: + """Test suite for NessusAPI connection methods.""" + + @patch('app.modules.nessus_api.requests.post') + def test_login_success(self, mock_post): + """Test successful login.""" + from app.modules.nessus_api import NessusAPI + + # Mock successful response + mock_response = Mock() + mock_response.json.return_value = {'token': 'test-token-12345'} + mock_post.return_value = mock_response + + api = NessusAPI( + url='https://test.nessus.local:8834', + username='testuser', + password='testpass' + ) + + token = api.login() + + assert token == 'test-token-12345' + assert api.token == 'test-token-12345' + mock_post.assert_called_once() + + @patch('app.modules.nessus_api.requests.delete') + def test_logout(self, mock_delete): + """Test logout functionality.""" + from app.modules.nessus_api import NessusAPI + + mock_response = Mock() + mock_delete.return_value = mock_response + + api = NessusAPI( + url='https://test.nessus.local:8834', + username='testuser', + password='testpass' + ) + api.token = 'test-token' + + api.logout() + mock_delete.assert_called_once() + + def test_get_session_token_creates_token(self): + """Test that get_session_token creates a new token if none exists.""" + from app.modules.nessus_api import NessusAPI + + api = NessusAPI( + url='https://test.nessus.local:8834', + username='testuser', + password='testpass' + ) + + # Mock the login method + with patch.object(api, 'login', return_value='new-token'): + token = api.get_session_token() + assert token == 'new-token' + + def test_get_session_token_returns_existing(self): + """Test that get_session_token returns existing token.""" + from app.modules.nessus_api import NessusAPI + + api = NessusAPI( + url='https://test.nessus.local:8834', + username='testuser', + password='testpass' + ) + api.token = 'existing-token' + + token = api.get_session_token() + assert token == 'existing-token' + + +class TestNessusAPIScanOperations: + """Test suite for NessusAPI scan operations.""" + + @patch('app.modules.nessus_api.requests.get') + def test_folders_list(self, mock_get): + """Test getting folder list.""" + from app.modules.nessus_api import NessusAPI + + mock_response = Mock() + mock_response.text = '{"folders": [{"id": 1, "name": "Test Folder"}]}' + mock_get.return_value = mock_response + + api = NessusAPI( + url='https://test.nessus.local:8834', + username='testuser', + password='testpass' + ) + api.token = 'test-token' + + folders = api.folders_list() + + assert 'folders' in folders + assert len(folders['folders']) == 1 + assert folders['folders'][0]['name'] == 'Test Folder' + + @patch('app.modules.nessus_api.requests.get') + def test_scans_list(self, mock_get): + """Test getting scans list.""" + from app.modules.nessus_api import NessusAPI + + mock_response = Mock() + mock_response.text = '{"scans": [{"id": 1, "name": "Test Scan"}]}' + mock_get.return_value = mock_response + + api = NessusAPI( + url='https://test.nessus.local:8834', + username='testuser', + password='testpass' + ) + api.token = 'test-token' + + scans = api.scans_list() + + assert 'scans' in scans + assert len(scans['scans']) == 1 + assert scans['scans'][0]['name'] == 'Test Scan' + + def test_update_payload_token(self): + """Test that update_payload_token adds token to payload.""" + from app.modules.nessus_api import NessusAPI + + api = NessusAPI( + url='https://test.nessus.local:8834', + username='testuser', + password='testpass' + ) + api.token = 'test-token' + + payload = {} + api.update_payload_token(payload) + + assert 'token' in payload + assert payload['token'] == 'test-token' From 542934597548514c37bc8bb7bc66737b38338f7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 21:00:39 +0000 Subject: [PATCH 3/5] Add explicit permissions to GitHub Actions workflows for security Co-authored-by: SchwartzKamel <49488751+SchwartzKamel@users.noreply.github.com> --- .github/workflows/ci.yml | 14 ++++++++++++++ .github/workflows/security.yml | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5360ebe..3830b52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: pull_request: branches: [main, develop] +# Restrict permissions for security +permissions: + contents: read + env: PYTHON_VERSION: "3.12" @@ -18,6 +22,8 @@ jobs: lint: name: Lint & Code Quality runs-on: ubuntu-24.04 + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@v4 @@ -51,6 +57,8 @@ jobs: name: Unit Tests runs-on: ubuntu-24.04 needs: lint + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@v4 @@ -106,6 +114,8 @@ jobs: name: Security Scan runs-on: ubuntu-24.04 needs: lint + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@v4 @@ -148,6 +158,8 @@ jobs: name: Docker Build runs-on: ubuntu-24.04 needs: [test, security] + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@v4 @@ -175,6 +187,8 @@ jobs: runs-on: ubuntu-24.04 needs: docker-build if: github.ref == 'refs/heads/main' + permissions: + contents: read services: redis: image: redis:7-alpine diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 498180e..8a9a018 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -12,10 +12,16 @@ on: # Run weekly on Sundays at midnight - cron: '0 0 * * 0' +# Restrict permissions for security +permissions: + contents: read + jobs: dependency-check: name: Dependency Vulnerability Check runs-on: ubuntu-24.04 + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@v4 @@ -58,6 +64,8 @@ jobs: code-security: name: Code Security Analysis runs-on: ubuntu-24.04 + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@v4 @@ -100,6 +108,8 @@ jobs: container-security: name: Container Security Scan runs-on: ubuntu-24.04 + permissions: + contents: read steps: - name: Checkout code uses: actions/checkout@v4 From 454b04d9593c03ce5a529e3df0539d27c40146ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Dec 2025 02:24:54 +0000 Subject: [PATCH 4/5] Add Docker Redis cluster support with master-replica-sentinel setup Co-authored-by: SchwartzKamel <49488751+SchwartzKamel@users.noreply.github.com> --- README.md | 21 ++++++++++ config.py | 29 ++++++++++++-- docker-compose.cluster.yml | 81 ++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 8 +++- 4 files changed, 134 insertions(+), 5 deletions(-) create mode 100644 docker-compose.cluster.yml diff --git a/README.md b/README.md index 2fe8ab9..cb26ae0 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,27 @@ docker compose down docker compose up -d --build ``` +### Redis Cluster Mode (High Availability) + +For production environments requiring high availability, use the cluster configuration: + +```bash +# Start with Redis cluster (master + replica + sentinel) +docker compose -f docker-compose.yml -f docker-compose.cluster.yml up -d + +# View cluster status +docker exec nessus_redis_master redis-cli info replication +docker exec nessus_redis_sentinel redis-cli -p 26379 sentinel master mymaster + +# Monitor failover +docker compose -f docker-compose.yml -f docker-compose.cluster.yml logs -f redis-sentinel +``` + +The cluster configuration provides: +- **Redis Master**: Primary read/write node +- **Redis Replica**: Read-only replica for failover +- **Redis Sentinel**: Monitors and handles automatic failover + ## Manual Installation ### Installing with uv (Recommended) diff --git a/config.py b/config.py index ce13263..6a5432c 100644 --- a/config.py +++ b/config.py @@ -1,13 +1,24 @@ """App configuration.""" from os import environ, path from dotenv import load_dotenv -import redis # Load variables from .env basedir = path.abspath(path.dirname(__file__)) load_dotenv(path.join(basedir, ".env")) +def get_redis_connection(): + """Get Redis connection with fallback handling.""" + redis_uri = environ.get('REDIS_URI') + if redis_uri: + try: + import redis + return redis.from_url(redis_uri) + except Exception: + return None + return None + + class Config: """Base config vars from .env file.""" SECRET_KEY = environ.get('SECRET_KEY') @@ -21,8 +32,8 @@ class Config: TEMPLATES_FOLDER = 'templates' # Flask-Session - SESSION_TYPE = environ.get('SESSION_TYPE') - SESSION_REDIS = redis.from_url(environ.get('REDIS_URI')) + SESSION_TYPE = environ.get('SESSION_TYPE', 'filesystem') + SESSION_REDIS = get_redis_connection() class ProdConfig(Config): @@ -42,3 +53,15 @@ class DevConfig(Config): SQLALCHEMY_DATABASE_URI = environ.get('DEV_DATABASE_URI') SQLALCHEMY_ECHO = True TEMPLATES_AUTO_RELOAD = True + + +class DockerConfig(Config): + """Docker config - uses Redis from docker-compose network.""" + FLASK_ENV = environ.get('FLASK_ENV', 'production') + DEBUG = False + TESTING = False + SQLALCHEMY_DATABASE_URI = environ.get('PROD_DATABASE_URI', 'sqlite:////app/nessus_visualizer.db') + SQLALCHEMY_ECHO = False + + # Redis is required in Docker environment + SESSION_TYPE = 'redis' diff --git a/docker-compose.cluster.yml b/docker-compose.cluster.yml new file mode 100644 index 0000000..c77cd0a --- /dev/null +++ b/docker-compose.cluster.yml @@ -0,0 +1,81 @@ +# NessusVisualizer Docker Compose - Redis Cluster Configuration +# High availability setup with Redis Sentinel for automatic failover +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.cluster.yml up -d +# +# This extends the base docker-compose.yml with: +# - Redis master-replica setup +# - Redis Sentinel for automatic failover +# - Multiple app replicas for load balancing + +services: + # Override the base Redis to be the master + redis: + container_name: nessus_redis_master + command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru + + # Redis replica for read operations and failover + redis-replica: + image: redis:7-alpine + container_name: nessus_redis_replica + restart: unless-stopped + command: redis-server --appendonly yes --replicaof redis 6379 + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - nessus_network + + # Redis Sentinel for automatic failover + redis-sentinel: + image: redis:7-alpine + container_name: nessus_redis_sentinel + restart: unless-stopped + command: > + sh -c "echo 'sentinel monitor mymaster redis 6379 1' > /tmp/sentinel.conf && + echo 'sentinel down-after-milliseconds mymaster 5000' >> /tmp/sentinel.conf && + echo 'sentinel failover-timeout mymaster 10000' >> /tmp/sentinel.conf && + echo 'sentinel parallel-syncs mymaster 1' >> /tmp/sentinel.conf && + redis-sentinel /tmp/sentinel.conf" + depends_on: + redis: + condition: service_healthy + redis-replica: + condition: service_healthy + ports: + - "26379:26379" + healthcheck: + test: ["CMD", "redis-cli", "-p", "26379", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - nessus_network + + # Override app to use Redis Sentinel + app: + environment: + - FLASK_APP=wsgi.py + - FLASK_ENV=production + - SECRET_KEY=${SECRET_KEY:-change-me-in-production} + - PROD_DATABASE_URI=${PROD_DATABASE_URI:-sqlite:////app/nessus_visualizer.db} + - SESSION_TYPE=redis + - REDIS_URI=redis://redis:6379/0 + - REDIS_SENTINEL_MASTER=mymaster + - REDIS_SENTINEL_HOSTS=redis-sentinel:26379 + - NESSUS_URL=${NESSUS_URL} + - NESSUS_USER=${NESSUS_USER} + - NESSUS_PASS=${NESSUS_PASS} + depends_on: + redis: + condition: service_healthy + redis-replica: + condition: service_healthy + redis-sentinel: + condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index ef9e82a..a790c5f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - "6379:6379" volumes: - redis_data:/data + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s @@ -34,12 +35,13 @@ services: - SECRET_KEY=${SECRET_KEY:-change-me-in-production} - PROD_DATABASE_URI=${PROD_DATABASE_URI:-sqlite:////app/nessus_visualizer.db} - SESSION_TYPE=redis - - REDIS_URI=redis://redis:6379 + - REDIS_URI=redis://redis:6379/0 - NESSUS_URL=${NESSUS_URL} - NESSUS_USER=${NESSUS_USER} - NESSUS_PASS=${NESSUS_PASS} volumes: - app_data:/app/data + - db_data:/app/instance depends_on: redis: condition: service_healthy @@ -47,7 +49,7 @@ services: test: ["CMD", "curl", "-f", "http://localhost:5000/login"] interval: 30s timeout: 10s - start_period: 10s + start_period: 15s retries: 3 networks: - nessus_network @@ -57,6 +59,8 @@ volumes: driver: local app_data: driver: local + db_data: + driver: local networks: nessus_network: From 3fdb6704acf7f5ed1c962c7360fc039dcb710bc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Dec 2025 02:27:02 +0000 Subject: [PATCH 5/5] Fix code review issues: specific exceptions and clarify cluster config Co-authored-by: SchwartzKamel <49488751+SchwartzKamel@users.noreply.github.com> --- config.py | 4 ++-- docker-compose.cluster.yml | 6 +++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/config.py b/config.py index 6a5432c..f7efc87 100644 --- a/config.py +++ b/config.py @@ -14,7 +14,7 @@ def get_redis_connection(): try: import redis return redis.from_url(redis_uri) - except Exception: + except (redis.ConnectionError, redis.TimeoutError, ImportError): return None return None @@ -62,6 +62,6 @@ class DockerConfig(Config): TESTING = False SQLALCHEMY_DATABASE_URI = environ.get('PROD_DATABASE_URI', 'sqlite:////app/nessus_visualizer.db') SQLALCHEMY_ECHO = False - + # Redis is required in Docker environment SESSION_TYPE = 'redis' diff --git a/docker-compose.cluster.yml b/docker-compose.cluster.yml index c77cd0a..8a8347c 100644 --- a/docker-compose.cluster.yml +++ b/docker-compose.cluster.yml @@ -58,7 +58,9 @@ services: networks: - nessus_network - # Override app to use Redis Sentinel + # Override app to include cluster info + # Note: REDIS_URI points to master for initial connection + # REDIS_SENTINEL_* vars are provided for future Sentinel-aware implementations app: environment: - FLASK_APP=wsgi.py @@ -66,7 +68,9 @@ services: - SECRET_KEY=${SECRET_KEY:-change-me-in-production} - PROD_DATABASE_URI=${PROD_DATABASE_URI:-sqlite:////app/nessus_visualizer.db} - SESSION_TYPE=redis + # Primary Redis connection (master node) - REDIS_URI=redis://redis:6379/0 + # Sentinel config for future HA-aware implementations - REDIS_SENTINEL_MASTER=mymaster - REDIS_SENTINEL_HOSTS=redis-sentinel:26379 - NESSUS_URL=${NESSUS_URL}