diff --git a/.audit-config.yaml b/.audit-config.yaml new file mode 100644 index 000000000..71aedcb5f --- /dev/null +++ b/.audit-config.yaml @@ -0,0 +1,28 @@ +# Dependency Vulnerability Audit Configuration +# This file documents known vulnerabilities and their exceptions + +version: 1.0 +policy: + # Minimum severity to block CI (critical, high, medium, low) + min_severity_to_block: high + + # Require approval for exceptions? + require_approval: true + + # Auto-fail if exception expires + enforce_expiry: true + + # Fail if any exception is close to expiry (days) + warn_before_expiry_days: 14 + +# Documented exceptions with business justification +# Format: CVE-XXXX-XXXXX or GHSA-xxxx-xxxx-xxxx +exceptions: {} + +# Packages to exclude from audits (use sparingly!) +excluded_packages: [] + +# Alert channels for new vulnerabilities +alerts: + slack_webhook: null + email_list: [] diff --git a/.env.example b/.env.example index e5cfd6d95..a850c3828 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,8 @@ SECUSCAN_BIND_PORT=8000 # Docker Support SECUSCAN_DOCKER_ENABLED=false +# Docker sandbox network (auto-created if absent; ICC disabled for isolation) +SECUSCAN_DOCKER_NETWORK=restricted # Security Defaults SECUSCAN_SAFE_MODE_DEFAULT=true @@ -22,6 +24,13 @@ SECUSCAN_ALLOW_LOOPBACK_SCANS=true # SECUSCAN_ALLOWED_NETWORKS=127.0.0.1,192.168.*.*,10.*.*.*,172.16.*.* # SECUSCAN_CORS_ALLOWED_ORIGINS=http://127.0.0.1:5173,http://localhost:5173 +# Network Policy & Admin Authentication +# SECUSCAN_NETWORK_ALLOWLIST= +# SECUSCAN_NETWORK_DENYLIST=169.254.169.254/32,127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 +# SECUSCAN_ENFORCE_NETWORK_POLICY=true +# SECUSCAN_NETWORK_POLICY_FAILURE_MODE=block +# SECUSCAN_ADMIN_API_KEY=replace-with-a-secure-admin-token + # Credential Vault — REQUIRED before first run # Generate with: python -c "import secrets; print(secrets.token_hex(32))" # The server refuses to start the vault if this is unset. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index b6df3791a..53b001b48 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: Create a report to help us improve SecuScan title: '[BUG] ' -labels: bug +labels: type:bug, priority:medium assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/documentation_task.md b/.github/ISSUE_TEMPLATE/documentation_task.md index cca325e6f..5c3d88ec9 100644 --- a/.github/ISSUE_TEMPLATE/documentation_task.md +++ b/.github/ISSUE_TEMPLATE/documentation_task.md @@ -2,7 +2,7 @@ name: Documentation task about: Propose a documentation improvement contributors can pick up directly title: '[DOCS] ' -labels: documentation +labels: type:docs, area:docs, priority:medium assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 760661e4d..1b82c3430 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,7 +2,7 @@ name: Feature request about: Suggest an idea for SecuScan title: '[FEAT] ' -labels: feature +labels: type:feature, priority:medium assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/help_wanted_task.md b/.github/ISSUE_TEMPLATE/help_wanted_task.md index 3350ff7dc..5dd6ac374 100644 --- a/.github/ISSUE_TEMPLATE/help_wanted_task.md +++ b/.github/ISSUE_TEMPLATE/help_wanted_task.md @@ -2,7 +2,7 @@ name: Help wanted task about: Create a scoped contributor task with clear implementation and verification notes title: '[TASK] ' -labels: help wanted +labels: priority:medium assignees: '' --- diff --git a/.github/workflows/catalog-sync-check.yml b/.github/workflows/catalog-sync-check.yml new file mode 100644 index 000000000..e108764ed --- /dev/null +++ b/.github/workflows/catalog-sync-check.yml @@ -0,0 +1,23 @@ +name: Plugin Catalog Sync Check + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + validate-catalog: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run Catalog Validation Script + run: python scripts/validate_plugins_catalog.py \ No newline at end of file diff --git a/.github/workflows/check-artifacts.yml b/.github/workflows/check-artifacts.yml index 6fb0108a9..7f520594d 100644 --- a/.github/workflows/check-artifacts.yml +++ b/.github/workflows/check-artifacts.yml @@ -1,14 +1,24 @@ -name: Check for Frontend Artifacts +name: Check for Committed Artifacts on: pull_request: branches: [main, dev] + paths: + - "frontend/**" + - "output/**" + - "data/**" + - "backend/data/**" + - "logs/**" + - "scripts/check-artifacts.sh" + - ".github/workflows/check-artifacts.yml" jobs: artifact-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - - name: Run artifact check - run: bash scripts/check-artifacts.sh + - name: Run artifact guard + run: bash scripts/check-artifacts.sh origin/main \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a97f397f..6f4e65340 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,27 @@ jobs: git fetch origin "${{ github.base_ref }}" --depth=1 git diff --check "origin/${{ github.base_ref }}"...HEAD + issue-template-label-validation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Validate issue template labels + run: python scripts/validate_issue_template_labels.py + + doc-anchor-validation: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Validate documentation anchors + run: python scripts/validate_doc_anchors.py + backend-lint: needs: detect-changes if: needs.detect-changes.outputs.run_backend == 'true' @@ -61,7 +82,29 @@ jobs: - name: Run backend lint baseline run: ruff check backend testing/backend - backend-tests: + backend-unit: + needs: [detect-changes, backend-lint, formatting-hygiene] + if: | + always() && + needs.detect-changes.outputs.run_backend == 'true' && + (needs.backend-lint.result == 'success' || needs.backend-lint.result == 'skipped') && + (needs.formatting-hygiene.result == 'success' || needs.formatting-hygiene.result == 'skipped') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install backend system dependencies + run: sudo apt-get update && sudo apt-get install -y libcairo2-dev pkg-config + - name: Install backend dependencies + run: | + python -m pip install --upgrade pip + pip install -r backend/requirements.txt -r backend/requirements-dev.txt + - name: Run unit tests + run: pytest testing/backend/unit -q -m "not benchmark" + + backend-integration: needs: [detect-changes, backend-lint, formatting-hygiene] if: | always() && @@ -80,8 +123,100 @@ jobs: run: | python -m pip install --upgrade pip pip install -r backend/requirements.txt -r backend/requirements-dev.txt - - name: Run backend tests - run: pytest testing/backend -q -m "not benchmark" + - name: Run integration tests + run: pytest testing/backend/integration -q -m "not benchmark" + + parser-contracts: + needs: [detect-changes, backend-lint, formatting-hygiene] + if: | + always() && + needs.detect-changes.outputs.run_backend == 'true' && + (needs.backend-lint.result == 'success' || needs.backend-lint.result == 'skipped') && + (needs.formatting-hygiene.result == 'success' || needs.formatting-hygiene.result == 'skipped') + runs-on: ubuntu-latest + strategy: + matrix: + capability_group: + - network + - intrusive + - credentials + - exploit + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install backend system dependencies + run: sudo apt-get update && sudo apt-get install -y libcairo2-dev pkg-config + - name: Install backend dependencies + run: | + python -m pip install --upgrade pip + pip install -r backend/requirements.txt -r backend/requirements-dev.txt + - name: Run parser contract tests for capability group ${{ matrix.capability_group }} + run: | + echo "Running parser contract tests for capability group: ${{ matrix.capability_group }}" + PARSER_CAPABILITY_GROUP=${{ matrix.capability_group }} pytest testing/backend/integration/test_parser_output_contract.py -q + backend-tests: + needs: + - backend-unit + - backend-integration + - parser-contracts + runs-on: ubuntu-latest + if: | + always() && + (needs.backend-unit.result == 'success' || needs.backend-unit.result == 'skipped') && + (needs.backend-integration.result == 'success' || needs.backend-integration.result == 'skipped') && + (needs.parser-contracts.result == 'success' || needs.parser-contracts.result == 'skipped') + steps: + - name: Backend test suites completed + run: echo "backend-unit, backend-integration, and parser-contracts completed" + backend-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install backend system dependencies + run: sudo apt-get update && sudo apt-get install -y libcairo2-dev pkg-config + - name: Install backend dependencies + run: | + python -m pip install --upgrade pip + pip install -r backend/requirements.txt -r backend/requirements-dev.txt + + - name: Run pip-audit on backend + id: pip_audit + run: | + mkdir -p ${{ github.workspace }}/backend + pip-audit -r backend/requirements.txt --desc --format json > ${{ github.workspace }}/backend/pip-audit-report.json || { + EXIT_CODE=$? + echo "EXIT_CODE=$EXIT_CODE" >> $GITHUB_OUTPUT + exit 0 # Check severity next + } + continue-on-error: true + + - name: Check pip-audit results + run: | + python ${{ github.workspace }}/scripts/check_pip_audit.py \ + --report ${{ github.workspace }}/backend/pip-audit-report.json \ + --config ${{ github.workspace }}/.audit-config.yaml + + - name: Generate CycloneDX SBOM + run: | + python ${{ github.workspace }}/scripts/generate_sbom.py --output ${{ github.workspace }}/sbom.json --include-dev + + - name: Upload pip-audit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: pip-audit-report + path: ${{ github.workspace }}/backend/pip-audit-report.json + + - name: Upload SBOM + uses: actions/upload-artifact@v4 + with: + name: sbom + path: ${{ github.workspace }}/sbom.json benchmark: runs-on: ubuntu-latest @@ -101,16 +236,18 @@ jobs: id: run_benchmarks run: python3 scripts/run_benchmarks.py continue-on-error: true - - name: Upload benchmark results artifact + - name: Upload benchmark comparison artifacts + if: always() uses: actions/upload-artifact@v4 with: - name: benchmark-results - path: benchmark_results.json + name: benchmark-threshold-comparison + path: | + benchmark_results.json + benchmark_threshold_comparison.json - name: Add warning annotation on failure if: steps.run_benchmarks.outcome == 'failure' run: | echo "::warning::Performance benchmark thresholds exceeded or benchmarks failed to run. Check the job logs for details." - frontend-checks: needs: [detect-changes, formatting-hygiene] if: | @@ -118,6 +255,10 @@ jobs: needs.detect-changes.outputs.run_frontend == 'true' && (needs.formatting-hygiene.result == 'success' || needs.formatting-hygiene.result == 'skipped') runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20, 22] + defaults: run: working-directory: frontend @@ -125,11 +266,30 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: ${{ matrix.node-version }} cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install frontend dependencies run: npm ci + - name: Run npm audit + id: npm_audit + run: | + npm audit --json > ${{ github.workspace }}/frontend/npm-audit-report.json || { + EXIT_CODE=$? + echo "EXIT_CODE=$EXIT_CODE" >> $GITHUB_OUTPUT + exit 0 # Check severity next + } + continue-on-error: true + - name: Install Python YAML library + run: pip install pyyaml + - name: Check npm audit results + run: python ${{ github.workspace }}/scripts/check_npm_audit.py --report ${{ github.workspace }}/frontend/npm-audit-report.json --config ${{ github.workspace }}/.audit-config.yaml + - name: Upload npm-audit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: npm-audit-report + path: frontend/npm-audit-report.json - name: Run frontend TypeScript typecheck run: npm run typecheck - name: Note TypeScript typecheck in job summary diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml new file mode 100644 index 000000000..491a16a19 --- /dev/null +++ b/.github/workflows/smoke-test.yml @@ -0,0 +1,235 @@ +name: Onboarding Smoke Test + +# Verifies the contributor setup path: +# fresh clone → ./setup.sh → ./start.sh → services respond +# Catches onboarding regressions before they hit contributors. + +on: + push: + branches: [main] + paths: + - "setup.sh" + - "start.sh" + - "backend/**" + - "frontend/**" + - "backend/requirements.txt" + - "backend/requirements-dev.txt" + - ".github/workflows/smoke-test.yml" + pull_request: + branches: [main] + paths: + - "setup.sh" + - "start.sh" + - "backend/**" + - "frontend/**" + - "backend/requirements.txt" + - "backend/requirements-dev.txt" + - ".github/workflows/smoke-test.yml" + workflow_dispatch: + +jobs: + shell-smoke: + name: start.sh shell smoke + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Validate start.sh syntax + run: bash -n start.sh + + - name: Verify frontend failure message + run: | + mv frontend frontend_backup + trap 'mv frontend_backup frontend' EXIT + + if bash start.sh > output.log 2>&1; then + echo "Expected start.sh to fail" + exit 1 + fi + + grep -q "ERROR: frontend directory not found." output.log \ + || { echo "Expected frontend error message not found"; cat output.log; exit 1; } + smoke-test: + name: Fresh-clone smoke test (Ubuntu) + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + # ── 1. Checkout ──────────────────────────────────────────────────────── + - name: Checkout repository + uses: actions/checkout@v4 + + # ── 2. Set up Python 3.11 ────────────────────────────────────────────── + # setup.sh requires Python 3.11+. We pin it explicitly so the runner + # never silently falls back to an older system Python. + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + # ── 3. Set up Node.js ────────────────────────────────────────────────── + # setup.sh checks for node + npm; frontend uses Vite on port 5173. + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + # ── 4. Make scripts executable ───────────────────────────────────────── + # Git on some clients strips execute bits — enforce them explicitly. + - name: Make scripts executable + run: chmod +x setup.sh start.sh + + # ── 5. Run setup.sh ──────────────────────────────────────────────────── + # setup.sh: creates venv, pip installs backend/requirements.txt + + # httpx[cli], npm installs frontend, writes .env, creates data/logs dirs. + # lsof is used by start.sh; install it here so setup.sh can't fail on it. + - name: Install system dependencies + run: sudo apt-get install -y lsof libcairo2-dev pkg-config python3-dev + + - name: Run setup.sh + run: | + echo "::group::setup.sh output" + bash setup.sh + echo "::endgroup::" + + # ── 6. Verify setup produced expected artifacts ──────────────────────── + # Fails fast with a clear message if setup.sh silently skipped something. + - name: Verify setup artifacts + run: | + echo "Checking venv..." + test -f venv/bin/python3 || { echo "FAIL: venv/bin/python3 missing"; exit 1; } + test -f venv/bin/activate || { echo "FAIL: venv/bin/activate missing"; exit 1; } + + echo "Checking backend deps..." + source venv/bin/activate + python3 -c "import fastapi" || { echo "FAIL: fastapi not installed"; exit 1; } + python3 -c "import uvicorn" || { echo "FAIL: uvicorn not installed"; exit 1; } + python3 -c "import httpx" || { echo "FAIL: httpx not installed"; exit 1; } + deactivate + + echo "Checking frontend node_modules..." + test -f frontend/node_modules/.bin/vite \ + || { echo "FAIL: frontend/node_modules/.bin/vite missing"; exit 1; } + + echo "Checking directories..." + for d in data data/raw data/reports logs wordlists; do + test -d "$d" || { echo "FAIL: directory '$d' missing"; exit 1; } + done + + echo "Checking .env..." + test -f .env || { echo "FAIL: .env not created"; exit 1; } + grep -q "SECUSCAN_BIND_PORT=8000" .env \ + || { echo "FAIL: expected port config missing from .env"; exit 1; } + + echo "All artifact checks passed." + + # ── 7. Start services via start.sh ───────────────────────────────────── + # start.sh launches uvicorn on 127.0.0.1:8000 and Vite on 127.0.0.1:5173. + # We background the whole script and capture its PID for cleanup. + - name: Start services via start.sh + run: | + bash start.sh & + echo "START_SH_PID=$!" >> "$GITHUB_ENV" + + # ── 8. Wait for backend (uvicorn on :8000) ───────────────────────────── + # start.sh starts uvicorn on 127.0.0.1:8000. + # /openapi.json is always present in FastAPI without any auth — safer + # than /health which may not exist. + - name: Wait for backend to be ready + run: | + MAX_WAIT=60 + INTERVAL=3 + ELAPSED=0 + URL="http://127.0.0.1:8000/openapi.json" + + echo "Polling $URL ..." + until curl --silent --fail --max-time 2 "$URL" > /dev/null 2>&1; do + if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then + echo "ERROR: Backend did not become ready within ${MAX_WAIT}s" + echo "--- Active processes ---" + ps aux | grep -E "uvicorn|python" || true + echo "--- Port 8000 status ---" + lsof -i :8000 || true + exit 1 + fi + echo " Not ready (${ELAPSED}s elapsed) — retrying in ${INTERVAL}s ..." + sleep "$INTERVAL" + ELAPSED=$((ELAPSED + INTERVAL)) + done + echo "Backend ready after ${ELAPSED}s." + + # ── 9. Wait for frontend (Vite on :5173) ────────────────────────────── + - name: Wait for frontend to be ready + run: | + MAX_WAIT=60 + INTERVAL=3 + ELAPSED=0 + URL="http://127.0.0.1:5173" + + echo "Polling $URL ..." + until curl --silent --fail --max-time 2 "$URL" > /dev/null 2>&1; do + if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then + echo "ERROR: Frontend did not become ready within ${MAX_WAIT}s" + echo "--- Active processes ---" + ps aux | grep -E "vite|node" || true + echo "--- Port 5173 status ---" + lsof -i :5173 || true + exit 1 + fi + echo " Not ready (${ELAPSED}s elapsed) — retrying in ${INTERVAL}s ..." + sleep "$INTERVAL" + ELAPSED=$((ELAPSED + INTERVAL)) + done + echo "Frontend ready after ${ELAPSED}s." + + # ── 10. Smoke-check backend API ──────────────────────────────────────── + # /openapi.json must contain "openapi" and "SecuScan" (the app title). + # /docs must return HTTP 200 (Swagger UI). + # These require zero auth and prove the real app stack loaded correctly. + - name: Smoke-check backend API + run: | + echo "--- GET /openapi.json ---" + OAS=$(curl --silent --fail --max-time 5 "http://127.0.0.1:8000/openapi.json") + echo "$OAS" | python3 -c "import sys, json; d=json.load(sys.stdin); assert 'openapi' in d, 'missing openapi key'" \ + || { echo "FAIL: /openapi.json invalid JSON or missing openapi key"; exit 1; } + echo "openapi.json OK" + + echo "--- GET /docs ---" + curl --silent --fail --max-time 5 "http://127.0.0.1:8000/docs" > /dev/null \ + || { echo "FAIL: /docs did not return 200"; exit 1; } + echo "/docs OK" + + echo "Backend smoke checks passed." + + # ── 11. Smoke-check frontend ─────────────────────────────────────────── + - name: Smoke-check frontend + run: | + echo "--- GET http://127.0.0.1:5173 ---" + BODY=$(curl --silent --fail --max-time 5 "http://127.0.0.1:5173") + echo "$BODY" | grep -qi "html" \ + || { echo "FAIL: frontend did not return an HTML page"; exit 1; } + echo "Frontend smoke check passed." + + # ── 12. Teardown ─────────────────────────────────────────────────────── + - name: Stop services + if: always() + run: | + [ -n "${START_SH_PID:-}" ] && kill "$START_SH_PID" 2>/dev/null || true + pkill -f "uvicorn" 2>/dev/null || true + pkill -f "vite" 2>/dev/null || true + pkill -f "npm" 2>/dev/null || true + echo "Teardown complete." + + # ── 13. Upload logs on failure ───────────────────────────────────────── + - name: Upload logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: smoke-test-logs + path: | + logs/ + **/*.log + nohup.out + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore index c069fe501..23c9bbcdb 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ env/ ENV/ .venv venv_tests/ +test_env/ # IDE .vscode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9bebba48f..40ab2bd85 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,12 +16,30 @@ SecuScan is built for learning, defensive security workflows, and ethical testin - Documentation fixes, setup clarification, and onboarding polish - Frontend UX improvements in `frontend/src` -- Backend validation, test coverage, and API consistency in `backend/secuscan` +- Backend validation, test coverage, and API consistency in `backend/secuscan` (see [docs/backend-architecture.md](docs/backend-architecture.md) for a module-by-module reference) - Plugin metadata cleanup and parser improvements in `plugins` - CI, test reliability, and developer experience When issue labels are available, look for tags such as `good first issue`, `documentation`, `frontend`, `backend`, `plugin`, `help wanted`, or `gssoc`. +## Issue Template Label Maintenance + +Issue templates in `.github/ISSUE_TEMPLATE/` must only reference labels from the active repository taxonomy. + +When adding or updating issue template labels: + +- Use active label groups such as `type:*`, `area:*`, `priority:*`, and `level:*`. +- Avoid deprecated labels such as `bug`, `feature`, `documentation`, and `help wanted`. +- Keep template labels aligned with the labels used by maintainers and CI. + +Before opening a pull request that changes issue templates, run: + +```bash +python scripts/validate_issue_template_labels.py +``` + +The CI workflow also runs this validation and will fail if an issue template references a label that is not included in the approved label taxonomy. + ## Local Setup ### Prerequisites @@ -38,6 +56,11 @@ When issue labels are available, look for tags such as `good first issue`, `docu ./start.sh ``` +Windows contributors should also read the +[`docs/windows_contributor_guide.md`](docs/windows_contributor_guide.md) guide +for PowerShell activation, Git Bash equivalents, Docker Desktop notes, and +Windows-specific troubleshooting. + This starts: - Backend: `http://127.0.0.1:8000` @@ -404,4 +427,4 @@ Never commit these auto-generated paths: If CI fails, run: ```bash git rm --cached -echo 'frontend/dist/' >> .gitignore \ No newline at end of file +echo 'frontend/dist/' >> .gitignore diff --git a/PLUGINS.md b/PLUGINS.md index 97528fde9..568a91630 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -6,8 +6,8 @@ Last synced: 2026-05-11 ## At a Glance -- Total plugins: 60 -- Safe plugins: 27 +- Total plugins: 59 +- Safe plugins: 26 - Intrusive plugins: 25 - Exploit plugins: 8 - Source of truth: each plugin's `metadata.json` @@ -26,14 +26,14 @@ Only run scans against systems you own or are explicitly authorized to assess. | Category | Count | | --- | ---: | -| `recon` | 19 | +| `recon` | 17 | | `vulnerability` | 12 | | `robots` | 5 | | `web` | 5 | | `exploit` | 5 | | `network` | 3 | | `expert` | 3 | -| `code` | 2 | +| `code` | 3 | | `forensics` | 2 | | `utils` | 2 | | `execution` | 1 | @@ -49,7 +49,7 @@ Only run scans against systems you own or are explicitly authorized to assess. | S3 / Blob Auditor | `cloud_storage_auditor` | `vulnerability` | `safe` | `uncover` | Find misconfigured S3 buckets and exposed cloud storage. | | Code Analyzer (Bandit) | `code_analyzer` | `code` | `safe` | `bandit` | Static analysis for Python code. | | Container Scan (Trivy) | `container_scanner` | `network` | `safe` | `trivy` | Scan Docker images and registries for known vulnerabilities. | -| Crawler | `crawler` | `robots` | `intrusive` | `katana` | Recursive web crawler for link discovery. | +| Crawler | `crawler` | `robots` | `intrusive` | `katana` | Depth-limited Katana crawl for recursive link discovery. | | Directory Discovery | `dir_discovery` | `web` | `intrusive` | `ffuf` | Discover hidden directories and files on web servers. | | DNS Reconnaissance | `dns_enum` | `recon` | `safe` | `dnsrecon` | Enumerate DNS records and configurations. | | dnsx | `dnsx` | `recon` | `safe` | `dnsx` | DNS resolution and wildcard-aware validation at scale. | @@ -60,11 +60,11 @@ Only run scans against systems you own or are explicitly authorized to assess. | Password Recovery Audit | `hashcat` | `expert` | `exploit` | `hashcat` | Password recovery and hash audit workflow. | | HTTP Inspector | `http_inspector` | `web` | `safe` | `curl` | Inspect HTTP/HTTPS endpoints for headers, cookies, and TLS configuration. | | HTTP Request Logger | `http_request_logger` | `exploit` | `intrusive` | `httpx` | Handle incoming HTTP requests and record data. | -| httpx | `httpx` | `recon` | `safe` | `httpx` | Live host probing with status, title, and technology fingerprinting. | +| httpx | `httpx` | `recon` | `safe` | `httpx` | Probe live hosts and collect reachability information, status codes, page titles, and basic technology indicators. | | IaC Scanner (Checkov) | `iac_scanner` | `vulnerability` | `safe` | `python3` | Analyze Terraform and CloudFormation code for flaws. | | ICMP Ping | `icmp_ping` | `utils` | `safe` | `ping` | Check if a server is live and responds to ICMP Echo requests. | | Joomla Security Scan | `joomscan` | `vulnerability` | `intrusive` | `joomscan` | Joomla security scanner for version and common weakness discovery. | -| Katana | `katana` | `recon` | `intrusive` | `katana` | Web crawling for endpoint and route discovery. | +| Katana | `katana` | `recon` | `intrusive` | `katana` | Baseline Katana URL discovery using the default crawl behavior. | | K8s Scanner | `kubernetes_scanner` | `vulnerability` | `intrusive` | `python3` | Kubernetes cluster security assessment. | | Exploitation Connector | `metasploit` | `expert` | `exploit` | `msfconsole` | Metasploit connector for controlled exploit-module execution. | | Network Scanner | `network_scanner` | `vulnerability` | `intrusive` | `nmap` | Check for 10,000+ CVEs and server misconfigurations. | @@ -76,18 +76,18 @@ Only run scans against systems you own or are explicitly authorized to assess. | Port Scanner | `port-scanner` | `recon` | `intrusive` | `nmap` | Detect open ports and fingerprint services. | | Advanced Network Recon | `scapy_recon` | `network` | `safe` | `python3` | Advanced network probing using Scapy. | | Secret Scanner | `secret_scanner` | `code` | `safe` | `gitleaks` | Scan directories for hardcoded secrets. | +| Semgrep Scanner | `semgrep_scanner` | `code` | `safe` | `semgrep` | Multi-language static code analysis using Semgrep. | | Sharepoint Scanner | `sharepoint_scanner` | `vulnerability` | `intrusive` | `nuclei` | Check SharePoint for security issues, misconfigs, and more. | -| Sitemap Generator | `sitemap_gen` | `robots` | `intrusive` | `katana` | Build complete XML sitemaps by autonomously parsing targets. | +| Sitemap Generator | `sitemap_gen` | `robots` | `intrusive` | `katana` | Depth-focused Katana crawl for sitemap-style URL inventory. | | Sniper: Auto-Exploiter | `sniper` | `exploit` | `exploit` | `python3` | Validate critical CVEs by automatic exploitation. | -| Spider | `spider` | `robots` | `intrusive` | `katana` | Advanced web spider with JS execution support. | -| SQL Injection Feasibility | `sqli_checker` | `expert` | `intrusive` | `ghauri` | SQL injection feasibility scanner powered by Ghauri. | -| SQLi Exploiter | `sqli_exploiter` | `exploit` | `exploit` | `sqlmap` | Exploit SQL injection in web apps to extract data. | -| SQL Injection Testing | `sqlmap` | `web` | `exploit` | `sqlmap` | Automatic SQL injection and database takeover tool. | +| Spider | `spider` | `robots` | `intrusive` | `katana` | JavaScript-aware Katana spider for deeper client-side route discovery. | +| SQL Injection Feasibility | `sqli_checker` | `expert` | `intrusive` | `ghauri` | Validates potential SQL injection vulnerabilities without exploitation. | +| SQLi Exploiter | `sqli_exploiter` | `exploit` | `exploit` | `sqlmap` | Exploitation-focused workflow for data extraction from confirmed SQL injection findings. | +| SQL Injection Testing | `sqlmap` | `web` | `exploit` | `sqlmap` | Detects SQL injection vulnerabilities and supports controlled database enumeration. | | SSH Runner | `ssh_runner` | `execution` | `intrusive` | `ssh` | Remote command execution via SSH. | -| Subdomain Finder | `subdomain-finder` | `recon` | `safe` | `subfinder` | Discover subdomains of a domain. | -| Subdomain Scanner | `subdomain_discovery` | `recon` | `safe` | `subfinder` | Enumerate subdomains using passive sources. | +| Subdomain Discovery (Configurable) | `subdomain_discovery` | `recon` | `safe` | `subfinder` | Comprehensive configurable subdomain enumeration via passive sources. Thread count and source coverage tunable via presets. | | Subdomain Takeover | `subdomain_takeover` | `exploit` | `intrusive` | `subfinder` | Discover dangling DNS entries pointing to external services. | -| Subfinder | `subfinder` | `recon` | `safe` | `subfinder` | Fast passive subdomain enumeration. | +| Subfinder (Quick) | `subfinder` | `recon` | `safe` | `subfinder` | Quick passive subdomain enumeration with minimal configuration — just provide a root domain. | | theHarvester | `theharvester` | `recon` | `safe` | `theHarvester` | OSINT collection for emails, domains, and hosts. | | TLS Security Analysis | `tls_inspector` | `security` | `safe` | `openssl` | Examine TLS/SSL certificates and cipher configurations. | | Uncover | `uncover` | `recon` | `safe` | `uncover` | Discover internet-exposed assets from external search sources. | @@ -95,15 +95,28 @@ Only run scans against systems you own or are explicitly authorized to assess. | urlfinder | `urlfinder` | `recon` | `safe` | `urlfinder` | Passive historical URL collection. | | Virtual Hosts Finder | `virtual-host-finder` | `recon` | `intrusive` | `ffuf` | Find multiple websites hosted on the same server. | | Volatility | `volatility` | `forensics` | `intrusive` | `volatility3` | Memory forensics workflow using Volatility 3 plugins. | -| WAF Detector | `waf-detection` | `recon` | `safe` | `wafw00f` | Fingerprint the Web Application Firewall behind target app. | | WAF Detector | `waf_detector` | `robots` | `safe` | `wafw00f` | Automatically identify Web Application Firewalls protecting targets. | -| Website Recon | `website-recon-2` | `recon` | `safe` | `httpx` | Fingerprint web technologies of target website. | +| Website Recon | `website-recon-2` | `recon` | `safe` | `httpx` | Perform website reconnaissance focused on identifying web technologies, frameworks, and application stack details. | | Domain Registration Lookup | `whois_lookup` | `utils` | `safe` | `python3` | Domain registration information lookup. | | WordPress Security Scan | `wpscan` | `vulnerability` | `intrusive` | `wpscan` | WordPress security scanner for plugin, theme, and core risk visibility. | | XSS Exploiter | `xss_exploiter` | `exploit` | `exploit` | `python3` | Exploit XSS in real-life attacks to extract cookies and data. | | Binary Signature Scan | `yara_scan` | `forensics` | `intrusive` | `yara` | Binary and file-system signature matching with YARA rules. | | DAST Web Proxy (ZAP) | `zap_scanner` | `vulnerability` | `exploit` | `python3` | Dynamic proxy spidering and payload injection. | +### Hashcat Output Artifacts + +- Session files for resumable runs +- Potfile entries containing recovered hashes +- Recovered credential output returned by the parser + +Review and remove these artifacts after authorized assessments. + +### SQL Injection Plugin Guidance + +- `sqli_checker` should be used to validate whether a target appears vulnerable to SQL injection and to assess feasibility before exploitation. +- `sqlmap` should be used for SQL injection testing and controlled database enumeration during assessment workflows. +- `sqli_exploiter` should be used only after a vulnerability has been confirmed and exploitation or data extraction is required. + ## Plugin Input Schema with Examples Plugins can tell us about configurable user inputs through schema fields in their @@ -115,44 +128,60 @@ Example schema: ```json { - "inputs": [ + "fields": [ { - "key": "target", + "id": "target", "label": "Target URL", - "type": "text", + "type": "string", "required": true, - "placeholder": "https://example.com" + "placeholder": "https://example.com", + "help": "Full URL of the target including scheme.", + "validation": { + "validation_type": "url", + "message": "Enter a valid URL starting with http:// or https://" + } }, { - "key": "scan_type", + "id": "scan_type", "label": "Scan Type", "type": "select", "required": true, - "options": ["quick", "full"] + "options": [ + { "value": "quick", "label": "Quick" }, + { "value": "full", "label": "Full" } + ] }, { - "key": "checks", + "id": "checks", "label": "Checks", "type": "multiselect", "required": false, - "options": ["headers", "ssl", "cookies"] + "options": [ + { "value": "headers", "label": "Headers" }, + { "value": "ssl", "label": "SSL" }, + { "value": "cookies", "label": "Cookies" } + ] }, { - "key": "recursive", + "id": "recursive", "label": "Enable Recursive Scan", - "type": "checkbox", + "type": "boolean", "required": false, "default": false }, { - "key": "timeout", + "id": "timeout", "label": "Timeout (seconds)", - "type": "number", + "type": "integer", "required": false, - "default": 30 + "default": 30, + "validation": { + "min": 1, + "max": 3600 + } }, { - "key": "wordlist_path", + "id": "wordlist_path", "label": "Wordlist Path", "type": "path", "required": false @@ -161,6 +190,19 @@ Example schema: } ``` +### Field Types + +| Type Value | Input Rendered | Notes | +| --- | --- | --- | +| `string` | Text input | Use `validation.validation_type` for URL, hostname, IP, etc. | +| `integer` | Number input | Use `validation.min` / `validation.max` for range | +| `boolean` | Toggle / checkbox | `default` should be `true` or `false` | +| `select` | Dropdown (single) | `options` must be `[{ "value": ..., "label": ... }]` | +| `multiselect` | Checkbox group | Same options shape as `select` | +| `path` | File-path text input | No validation block needed | + +For the full list of named validation presets (e.g. `url`, `hostname`, `domain`, `ipv4`, `port`, `cidr`) and range rules, see [plugin-validation.md](docs/plugin-validation.md). + ### Required vs Optional Fields - `"required": true` means that the user must provide a value before running the plugin. @@ -171,20 +213,27 @@ Example schema: Plugin presets shall map directly to schema keys. -Example preset: +Example presets: ```json { - "preset": { - "target": "https://example.com", - "scan_type": "quick", - "recursive": true, - "timeout": 60 + "presets": { + "quick": { + "target": "https://example.com", + "scan_type": "quick", + "recursive": false, + "timeout": 30 + }, + "thorough": { + "scan_type": "full", + "recursive": true, + "timeout": 300 + } } } ``` -Each preset key shall exactly match a corresponding schema `"key"` value. +Each preset key shall exactly match a corresponding field `"id"` value. ## Maintenance Notes @@ -270,3 +319,47 @@ python scripts/validate_plugin.py --plugin plugins/nmap The validation checks metadata JSON, required fields, checksums, and custom parser imports when applicable. + +### Metadata quality lint rules + +Two additional lint checks help maintain high-quality plugin metadata: + +1. **Missing field help text** — Each field in the `fields` array should include + a `help` string that provides a brief user-facing description of the input. + Fields without `help` text produce a lint **warning** (the plugin is still valid). + + ```json + // Good — has help text + { "id": "target", "label": "Target", "type": "text", "help": "IP address or hostname to scan" } + + // Bad — missing help text (lint warning) + { "id": "target", "label": "Target", "type": "text" } + ``` + +2. **Ambiguous category** — Each plugin's `category` must be one of the + recognized categories: `recon`, `vulnerability`, `web`, `exploit`, `network`, + `expert`, `code`, `forensics`, `utils`, `execution`, `security`, `robots`. + Unknown or misspelled categories cause a validation **error** and block + the plugin from being loaded. + + ```bash + # Run the linter + python scripts/validate_plugins.py + ``` + +Existing plugins can be brought into compliance incrementally — the help +text check is a non-blocking warning, and unknown categories cause a +clear error message identifying the problem. + +--- + +--- + +## Catalog Validation (For Contributors) + +To prevent the index, categories, and metrics in this file from drifting out of sync with the live plugin directories, a validation tool is provided. + +Before submitting a Pull Request that adds, removes, or modifies a plugin, ensure the catalog is synced by running: + +```bash +python scripts/validate_plugins_catalog.py diff --git a/README.md b/README.md index d84cbb5e0..7c1a7b625 100644 --- a/README.md +++ b/README.md @@ -1,81 +1,148 @@

- SecuScan Logo + SecuScan logo

SecuScan

- Local-first security scanning for learning, experimentation, and ethical pentesting workflows. + Local-first security scanning workspace for authorized testing, learning, and extensible automation.

- - License: MIT - - - Python 3.11+ - - - React and Vite - - - Active Development - + MIT License + Python 3.11+ + React and TypeScript + 60 catalogued plugins

-## Project Purpose +> **Authorized use only:** Run SecuScan only against systems you own, systems you are explicitly permitted to assess, or deliberately vulnerable lab environments. -SecuScan is an open source, plugin-driven platform for running security scans from your own machine. It combines a FastAPI backend, a React frontend, and a growing plugin system for recon, web, cloud, container, and reporting workflows. +## Overview -The project is designed to be: +SecuScan is an open-source workspace for running and organizing authorized security scans. It combines a React frontend, a FastAPI backend, a metadata-driven plugin system, workflow automation, normalized findings, reports, and safety controls. -- Local-first: scan data stays on infrastructure you control. -- Contributor-friendly: frontend, backend, plugins, and docs all have clear entry points. -- Safety-aware: the product is built around ethical and learning-oriented usage. +It is useful for students, contributors, security learners, and practitioners who want one local place to configure scanners, run tasks, review output, and build new integrations. It is not a replacement for professional manual testing or a full penetration-testing distribution. -## Who It Is For +## At a Glance -- Students and GSSoC contributors who want a real-world full-stack open source security project. -- Security learners who want a UI-backed toolkit instead of only raw CLI flows. -- Developers and researchers who want to extend scanners, parsers, reports, or workflow automation. +- Local-first app with local scan history, reports, logs, and runtime data. +- React + TypeScript frontend with plugin-driven forms. +- FastAPI backend with API-key authentication and OpenAPI docs. +- 60 catalogued plugin integrations from `plugins/*/metadata.json`. +- Safety levels: 27 `safe`, 25 `intrusive`, and 8 `exploit`. +- Task controls for consent, safe mode, rate limits, concurrency, network policy, and capabilities. +- Real-time task status and output streaming. +- Reports, finding normalization, grouping, workflows, and plugin validation helpers. -## Core Areas +## Architecture -- Scan orchestration and API flows in `backend/secuscan` -- React UI and dashboard experience in `frontend/src` -- Plugin metadata and parser integrations in `plugins` -- Reports, exports, and result normalization across backend and frontend +```mermaid +flowchart LR + U[User] --> UI[React Frontend] + UI -->|REST API| API[FastAPI Backend] + API -->|SSE task events| UI -## Repository Map + API --> AUTH[API Key Auth] + API --> PM[Plugin Manager] + API --> WF[Workflow Scheduler] + API --> EX[Task Executor] -- `backend/`: FastAPI app, execution logic, database/config, plugin loading, workflows -- `frontend/`: React + Vite app, routes, pages, shared components, and test config -- `plugins/`: scanner metadata, parser code, and plugin-specific helpers -- `testing/backend/`: Python unit and integration tests plus backend test scripts -- `frontend/testing/`: frontend unit and end-to-end test files -- `docs/`: supporting project documentation -- `scripts/`: helper scripts for signing, benchmarking, and maintenance + PM --> META[metadata.json] + PM --> PARSER[parser.py] -## Prerequisites + EX --> GUARD[Consent, Safe Mode, Rate, Capability, Network Policy] + GUARD --> ENGINE{Execution Engine} -For a fresh local setup, make sure your machine has: + ENGINE --> CLI[CLI Tools] + ENGINE --> PY[Python Scanners] + ENGINE --> DOCKER[Docker Execution] + PARSER --> SANDBOX[Parser Sandbox] -- `python3` 3.11 or newer -- Node.js 20 or newer -- npm 10 or newer -- Docker Desktop or Docker Engine if you want the Compose workflow + CLI --> NORM[Normalized Findings] + PY --> NORM + DOCKER --> NORM + SANDBOX --> NORM -If your machine has multiple Python versions installed, `./setup.sh` now looks for a compatible `python3` automatically. You can also force one explicitly with `PYTHON=/path/to/python3.11 ./setup.sh`. + NORM --> DB[(SQLite or PostgreSQL)] + EX --> CACHE[(Memory Cache or Redis)] + EX --> FS[(Reports and Artifacts)] +``` -The scripted local setup path was re-checked from a fresh clone with a compatible Python 3.11+ interpreter. +## Scan Flow + +```mermaid +sequenceDiagram + actor User + participant UI as Frontend + participant API as Backend API + participant PM as Plugin Manager + participant EX as Executor + participant DB as Database + participant Tool as Scanner + + User->>UI: Select plugin and target + UI->>API: Fetch plugin schema + API->>PM: Load metadata + PM-->>UI: Fields, presets, safety, availability + User->>UI: Confirm consent and start scan + UI->>API: Create task + API->>EX: Validate and queue + EX->>Tool: Run scanner + Tool-->>EX: Output and status + EX-->>UI: Stream updates + EX->>DB: Store normalized result +``` -## Quick Start +## Core Pieces + +- **Frontend:** scanner catalogue, plugin forms, task views, findings, reports, workflows, dashboard, settings, and API-key setup. +- **Backend:** API routes, authentication, task lifecycle, reports, workflow scheduling, vault, notifications, cache, and database setup. +- **Plugins:** metadata files define UI fields, engine type, safety level, dependencies, capabilities, presets, and output behavior. +- **Executor:** validates tasks, runs scanners, streams output, normalizes results, writes audit data, and handles cancellation. +- **Parser sandbox:** runs custom `parser.py` code in a separate process with integrity checks and output limits. +- **Storage:** SQLite for simple local use, PostgreSQL and Redis in Docker Compose, filesystem for reports and artifacts. +- **Security controls:** consent, safe mode, network policy, plugin checksums, capability denial, rate limits, and concurrency limits. + +## Repository Map + +```text +SecuScan/ +├── backend/ FastAPI app, scanners, config, database, executor +├── frontend/ React, TypeScript, Vite UI +├── plugins/ Plugin metadata, parsers, helpers +├── testing/ Backend/shared test utilities +├── docs/ Product, deployment, auth and contributor docs +├── scripts/ Plugin validation, checksums, signing, benchmarks +├── assets/ Logo and branding assets +├── data/ Shared/runtime data +├── wordlists/ Scanner wordlists +└── .github/ CI, issue templates, automation +``` +## Quick Links + +- **Backend**: [`backend/`](backend/) — FastAPI app, scanners, executor. See [Backend Architecture](docs/backend-architecture.md) +- **Frontend**: [`frontend/`](frontend/) — React + TypeScript UI +- **Plugins**: [`plugins/`](plugins/) — Plugin metadata and parsers. See [Plugin Validation](docs/plugin-validation.md) +- **Docs**: [`docs/`](docs/) — Product, deployment, auth, and contributor docs +- **Scripts**: [`scripts/`](scripts/) — Validation, checksums, signing, benchmarks +- **Testing**: [`testing/`](testing/) — Backend/shared test utilities + +## Requirements + +- Python 3.11+ +- Node.js 20+ +- npm 10+ +- Docker Desktop or Docker Engine for Compose or Docker-backed scans -Choose one local development path. +If multiple Python versions are installed, `./setup.sh` tries to find a compatible `python3`. You can also force one: -### Option 1: Simple Local Dev +```bash +PYTHON=/path/to/python3.11 ./setup.sh +``` + +## Quick Start -This is the fastest way to get the app running for UI or backend contributions from a fresh clone. +### Local Dev ```bash git clone https://github.com/utksh1/SecuScan.git @@ -85,15 +152,20 @@ chmod +x setup.sh start.sh ./start.sh ``` -After startup: +Open: - Frontend: `http://127.0.0.1:5173` - Backend API: `http://127.0.0.1:8000` - Swagger docs: `http://127.0.0.1:8000/docs` +- ReDoc: `http://127.0.0.1:8000/redoc` -### Option 2: Docker Compose Stack +The backend creates an API key at `backend/data/.api_key`. If the frontend asks for it: -Use this if you want the containerized app stack with Postgres and Redis. +```bash +cat backend/data/.api_key +``` + +### Docker Compose ```bash git clone https://github.com/utksh1/SecuScan.git @@ -101,287 +173,232 @@ cd SecuScan docker compose up --build ``` -After startup: +Open: - Frontend: `http://127.0.0.1:5173` - Backend API: `http://127.0.0.1:8081` +- PostgreSQL: `127.0.0.1:5432` +- Redis: `127.0.0.1:6379` -### Troubleshooting / Common Issues - -For more detailed Windows-specific setup and troubleshooting instructions, see the [Windows Contributor Development Guide](docs/windows_contributor_guide.md). +## Manual Dev Commands -#### Python version issues - -This project requires Python 3.11 or newer. - -Check your Python version: +Backend: ```bash -python --version +cp .env.example .env +python3 -m venv venv +source venv/bin/activate +pip install -r backend/requirements.txt +pip install -r backend/requirements-dev.txt +python3 -m uvicorn backend.secuscan.main:app --reload --host 127.0.0.1 --port 8000 ``` -or: +Frontend: ```bash -python3 --version +cd frontend +npm install +npm run dev -- --host 127.0.0.1 --port 5173 ``` -Install Python 3.11+ if your current version is older. +## Configuration -Python downloads: https://www.python.org/downloads/ - -#### Virtual environment activation issues - -Create a virtual environment: +Copy `.env.example` before changing local settings: ```bash -python -m venv venv +cp .env.example .env ``` -Activate it: +Common settings: -**Windows PowerShell** +| Variable | Purpose | +| --- | --- | +| `SECUSCAN_BIND_ADDRESS`, `SECUSCAN_BIND_PORT` | Backend host and port. | +| `SECUSCAN_SAFE_MODE_DEFAULT` | Enables safer target validation defaults. | +| `SECUSCAN_REQUIRE_CONSENT` | Requires consent before task creation. | +| `SECUSCAN_VAULT_KEY` | Required seed for credential vault encryption. | +| `SECUSCAN_DOCKER_ENABLED` | Enables Docker-backed task execution where supported. | +| `SECUSCAN_NETWORK_ALLOWLIST`, `SECUSCAN_NETWORK_DENYLIST` | Network policy controls. | +| `SECUSCAN_DENIED_CAPABILITIES` | Blocks plugins requiring selected capabilities. | +| `VITE_API_BASE` | Frontend API base override. | -```powershell -venv\Scripts\Activate.ps1 -``` +See [docs/api-authentication.md](docs/api-authentication.md) and [docs/SECURE_DEPLOYMENT.md](docs/SECURE_DEPLOYMENT.md). -**Windows Git Bash** +## Tests + +Backend: ```bash -source venv/Scripts/activate +./testing/test_python.sh ``` -**Linux/macOS** +Frontend: ```bash -source venv/bin/activate +cd frontend +npm run test ``` -If PowerShell blocks activation, run: +End-to-end: -```powershell -Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser +```bash +cd frontend +npm run e2e ``` -Then reopen the terminal and activate the virtual environment again. - -#### Node.js version issues - -This project requires Node.js 20 or newer. - -Check your Node.js version: +Plugin validation: ```bash -node -v +python scripts/validate_plugins.py +python scripts/validate_plugin.py --plugin nmap +python scripts/refresh_plugin_checksum.py --plugin nmap ``` -Install Node.js 20+ if your current version is older. - -Node.js downloads: https://nodejs.org/ - -#### Dependency installation issues - -If dependency installation fails, try reinstalling dependencies: +## API Examples ```bash -npm install +API_KEY=$(cat backend/data/.api_key) +curl http://127.0.0.1:8000/api/v1/health +curl -H "X-API-Key: $API_KEY" http://127.0.0.1:8000/api/v1/plugins +curl -H "X-API-Key: $API_KEY" http://127.0.0.1:8000/api/v1/plugin/nmap/schema ``` -If installation still fails, try clearing the npm cache: +Start a task: ```bash -npm cache clean --force +curl -X POST http://127.0.0.1:8000/api/v1/task/start \ + -H "Content-Type: application/json" \ + -H "X-API-Key: $API_KEY" \ + -d '{ + "plugin_id": "nmap", + "inputs": {"target": "127.0.0.1"}, + "consent_granted": true + }' ``` -Then reinstall dependencies again: +## Security Model -```bash -npm install -``` +SecuScan includes safety controls, but users still need judgment. -For complete contributor workflow and coding guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md). +- Use it only on owned, authorized, or lab systems. +- Plugin safety labels are guidance, not a guarantee. +- Docker improves isolation but is not a complete security boundary. +- External tools keep their own risks, licenses, and behavior. +- Automated findings need manual validation. +- Remote databases, webhooks, cloud APIs, LLMs, or external targets can move data off your machine. +- Do not expose the backend to untrusted networks without hardening. +- Read [SECURITY.md](SECURITY.md) and [docs/SECURE_DEPLOYMENT.md](docs/SECURE_DEPLOYMENT.md). -#### Environment variable issues +## Adding Plugins -If the app fails because environment variables are missing, copy the example environment file if available: +A typical plugin looks like: -```bash -cp .env.example .env +```text +plugins/example_plugin/ +├── metadata.json +└── parser.py ``` -Then update the required values before starting the project. +Before opening a plugin PR: -#### Still stuck? +1. Keep metadata accurate and scoped. +2. Set the correct safety level. +3. Declare required capabilities. +4. Validate inputs and avoid shell interpolation. +5. Add parser tests when parser behavior changes. +6. Document required binaries. +7. Refresh checksums. +8. Update `PLUGINS.md` if the catalogue changes. -Before opening a setup issue, check: +Start with [PLUGINS.md](PLUGINS.md), [docs/plugin-validation.md](docs/plugin-validation.md), and [CONTRIBUTING.md](CONTRIBUTING.md). -* Python 3.11+ -* Node.js 20+ -* virtual environment is activated -* required `.env` files exist +## Contributing -## Manual Development Commands +Good contribution areas: -### Backend +- setup and documentation clarity; +- frontend empty/loading/error states; +- accessibility improvements; +- backend validation and API consistency; +- workflow and task lifecycle tests; +- plugin metadata fixes; +- parser normalization; +- security hardening. -> **Python version:** `python3` in these commands must resolve to 3.11 or newer. If your system default is older, substitute the full path (e.g. `python3.11`, `python3.12`) or use `PYTHON=/path/to/python3.11 ./setup.sh` instead. Run `python3 --version` to check. +Before a PR, branch from `main`, keep the change focused, add tests for behavior changes, update docs when needed, and avoid unrelated formatting churn. -```bash -cp .env.example .env -python3 -m venv venv -source venv/bin/activate -pip install -r backend/requirements.txt -pip install -r backend/requirements-dev.txt -python3 -m uvicorn backend.secuscan.main:app --reload --host 127.0.0.1 --port 8000 -``` +## Troubleshooting -### Frontend +- Python must be 3.11+: `python3 --version` +- If venv activation fails on Windows PowerShell, use `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` +- If frontend dependencies fail, run `cd frontend && npm install --legacy-peer-deps` +- If Vite cache is stale, run `cd frontend && npm run dev -- --force` +- If ports are busy, stop processes on `5173` and `8000` +- If env vars are missing, run `cp .env.example .env` +- For Windows setup, see [docs/windows_contributor_guide.md](docs/windows_contributor_guide.md) -```bash -cd frontend -npm install -npm run dev -- --host 127.0.0.1 --port 5173 -``` +### Local Startup Troubleshooting + +| Issue | Possible Cause | Resolution | +|---------|---------------|------------| +| `./start.sh: Permission denied` | Script is not executable | Run `chmod +x start.sh setup.sh` and retry. | +| `python3: command not found` | Python is missing or not on PATH | Install Python 3.11+ and verify with `python3 --version`. | +| Virtual environment creation fails | Python venv support is unavailable | Install Python venv packages and rerun `./start.sh`. | +| Backend dependency installation fails | Missing or incompatible Python packages | Re-run `pip install -r backend/requirements.txt` inside the virtual environment. | +| `npm: command not found` | Node.js or npm is not installed | Install Node.js 20+ and npm 10+. | +| Frontend dependency installation fails | Corrupted or missing dependencies | Run `cd frontend && npm install`. | +| `lsof: command not found` | Required utility is missing | Install `lsof` using your system package manager. | +| Backend does not start on port 8000 | Port conflict or existing process | Run `lsof -i :8000`, terminate the conflicting process, and retry. | +| Frontend does not start on port 5173 | Port conflict or Vite startup failure | Run `lsof -i :5173`, terminate the conflicting process, and retry. | +| Frontend cannot connect to backend | Backend failed to start or API configuration issue | Verify backend is available at `http://127.0.0.1:8000`. | +| API key not found | Backend has not generated the key yet | Check `backend/data/.api_key` after backend startup. | + +> Note: `start.sh` automatically attempts to terminate processes using ports `8000` and `5173` before starting the backend and frontend. Manual cleanup may still be required if the ports remain occupied. -## Running Tests +#### Checking Port Conflicts -### Backend tests +Backend: ```bash -./testing/test_python.sh +lsof -i :8000 ``` -### Frontend tests +Frontend: ```bash -cd frontend -npm run test +lsof -i :5173 ``` -### Frontend end-to-end tests +Terminate a process: ```bash -cd frontend -npm run e2e +kill -9 ``` -## New Contributors Start Here - -If this is your first contribution, start with one of these areas: +For Windows-specific startup and troubleshooting guidance, see [docs/windows_contributor_guide.md](docs/windows_contributor_guide.md). -- Docs: improve setup steps, fix outdated instructions, or clarify contributor guidance. -- Frontend polish: small UI fixes, loading states, empty states, and test coverage. -- Backend cleanup: validation, API consistency, workflow edge cases, and unit tests. -- Plugins: metadata fixes, parser improvements, and result normalization. +If you prefer to start services manually instead of using `start.sh`, see the **Manual Dev Commands** section above. -Good first places to read before coding: +## More Docs -- [Contribution Guide](CONTRIBUTING.md) -- [Code of Conduct](CODE_OF_CONDUCT.md) -- [Security Policy](SECURITY.md) -- [Plugin Catalogue](PLUGINS.md) - [Product Specification](docs/PRODUCT_SPEC.md) - -## Contribution Guidelines - -Before opening a pull request: - -1. Fork the repo and branch from `main`. -2. Pick an issue or discuss the change before starting large work. -3. Keep pull requests scoped and include tests when behavior changes. -4. Update docs if you change setup, APIs, workflows, or contributor-facing behavior. - -Detailed contributor expectations live in [CONTRIBUTING.md](CONTRIBUTING.md). - -## Detailed Documentation - -Long-form product and planning material lives outside the main README so onboarding stays readable: - -- [SecuScan Product Specification](docs/PRODUCT_SPEC.md) - [Plugin Catalogue](PLUGINS.md) +- [Plugin Validation](docs/plugin-validation.md) +- [API Authentication](docs/api-authentication.md) - [Secure Deployment Guide](docs/SECURE_DEPLOYMENT.md) - [Windows Contributor Development Guide](docs/windows_contributor_guide.md) +- [Frontend README](frontend/README.md) +## Project Status -## Tech Stack - -- Backend: FastAPI, Pydantic, Uvicorn, SQLite/Postgres, Redis -- Frontend: React 18, TypeScript, Vite, Vitest, Playwright -- Plugins: metadata-driven scanner integrations and parser modules - -## Contact - -For questions, contributor coordination, onboarding help, or setup issues, use [GitHub Issues](https://github.com/utksh1/SecuScan/issues). - -For responsible disclosure of security issues, follow the private reporting guidance in [SECURITY.md](SECURITY.md). - -## Responsible Use - -SecuScan is intended for authorized security testing, education, and research. Do not use it against systems you do not own or explicitly have permission to assess. +SecuScan is under active development. APIs, plugin schemas, UI flows, and execution behavior may change before a stable release. Version values are not fully standardized across every surface yet. ## License -This project is released under the [MIT License](LICENSE). - -## Licensing Notes - -- `LICENSE` is the canonical legal text for this repository. -- Contributions merged into this repository are distributed under the same MIT License unless explicitly stated otherwise. -- Third-party tools, libraries, and external scanners referenced by SecuScan may have their own licenses and usage terms. Check upstream projects before redistributing bundled integrations. - +SecuScan is licensed under the [MIT License](LICENSE). Third-party tools and scanners may use different licenses and usage terms. ---- +## Contributors -## Troubleshooting & Local Setup Failsafe +Thanks to everyone contributing code, plugins, tests, docs, design, issue triage, and reviews. -Use these checks when local installation or launch fails. - -### 1. Stale Local Vite Module Cache - -**Symptoms:** Frontend changes do not appear in the browser, or Vite reports internal parsing or bundling errors. - -**Fix:** Force Vite to ignore its stale cache and run a fresh reload: - -```bash -cd frontend -npm run dev -- --force -``` - -### 2. Node Dependency Resolution Loops (`npm i` hanging/failing) - -**Symptoms:** `npm install` reports dependency tree conflicts, peer dependency errors, or hangs indefinitely. - -**Fix:** Retry with the legacy peer dependency resolver: - -```bash -npm install --legacy-peer-deps -``` - -### 3. Missing or Mismatched Environment Variables - -**Symptoms:** The frontend loads, but API requests fail or scans cannot connect to the backend. - -**Fix:** Create a local `.env` file from the example file: - -```bash -cp .env.example .env -``` - -### 4. Port 5173 Already in Use - -**Symptoms:** Vite reports that port `5173` is already in use and switches to another port. - -**Fix:** Stop the process using that port. - -Windows PowerShell: - -```powershell -Stop-Process -Id (Get-NetTCPConnection -LocalPort 5173).OwningProcess -Force -``` - -Linux or macOS: - -```bash -kill "$(lsof -t -i:5173)" -``` +[View contributors](https://github.com/utksh1/SecuScan/graphs/contributors) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index e780a0395..daa3bf5f7 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -4,4 +4,6 @@ httpx>=0.28.1 ruff>=0.15.12 pytest-asyncio>=0.24.0 anyio>=4.0.0 +pip-audit>=2.6.0 +pyyaml>=6.0 trio>=0.27.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index b7d7a8517..89748e341 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,5 @@ python-multipart>=0.0.9 xhtml2pdf>=0.2.17 aiosqlite>=0.20.0 python-whois>=0.9.4 +httpx>=0.28.1 +openai>=1.0.0 diff --git a/backend/secuscan/ai_summary.py b/backend/secuscan/ai_summary.py new file mode 100644 index 000000000..0ac2d3ae7 --- /dev/null +++ b/backend/secuscan/ai_summary.py @@ -0,0 +1,146 @@ +""" +ai_summary.py — LLM-powered executive summary generation for SecuScan reports. + +Opt-in feature: returns an empty string when disabled or when the openai +package is not installed. Zero impact on existing report behaviour. + +Supports any OpenAI-compatible endpoint: + - OpenAI: leave AI_SUMMARY_BASE_URL blank + - Ollama: AI_SUMMARY_BASE_URL=http://localhost:11434/v1 + - Any other local / cloud LLM with a /v1/chat/completions endpoint +""" + +from __future__ import annotations + +import logging +import re +from collections import Counter +from typing import Optional + +logger = logging.getLogger(__name__) + +# Top-level import so the symbol can be patched in tests. +# If openai is not installed this will be None and generate_summary() handles it. +try: + from openai import OpenAI +except ImportError: # pragma: no cover + OpenAI = None # type: ignore[misc,assignment] + +# Matches URLs, IPs, hostnames, and common credential patterns +_SENSITIVE_RE = re.compile( + r"https?://\S+" # URLs + r"|(?:\d{1,3}\.){3}\d{1,3}" # IPv4 + r"|(?:[a-zA-Z0-9-]+\.){2,}[a-zA-Z]{2,}" # hostnames + r"|(?:password|passwd|token|secret|key|auth|credential)[:=]\S+", # credentials + re.IGNORECASE, +) + + +def _sanitize_title(title: str) -> str: + """Remove hostnames, IPs, URLs, and credentials from a finding title.""" + return _SENSITIVE_RE.sub("[redacted]", title).strip() + + +def _build_prompt(findings: list[dict]) -> str: + """Build a privacy-safe prompt from finding metadata only. + + Titles are sanitized to remove any embedded hostnames, IPs, URLs, or + credentials before being included in the prompt. + """ + total = len(findings) + + severity_counts: Counter = Counter() + categories: Counter = Counter() + top_findings: list[str] = [] + + for f in findings: + sev = str(f.get("severity", "unknown")).lower() + severity_counts[sev] += 1 + cat = str(f.get("category") or f.get("type") or "general") + categories[cat] += 1 + + priority_sevs = {"critical", "high"} + for f in findings: + sev = str(f.get("severity", "")).lower() + if sev in priority_sevs and len(top_findings) < 5: + raw_title = ( + f.get("title") or f.get("name") or f.get("check") or "Unnamed finding" + ) + title = _sanitize_title(str(raw_title)) + top_findings.append(f"[{sev.upper()}] {title}") + + sev_summary = ", ".join( + f"{count} {sev}" for sev, count in severity_counts.most_common() + ) + top_cats = ", ".join(cat for cat, _ in categories.most_common(5)) + top_findings_text = ( + "\n".join(f" - {t}" for t in top_findings) + if top_findings + else " (none in critical/high range)" + ) + + return ( + "You are a cybersecurity analyst writing an executive summary for a " + "security scan report.\n\n" + "Scan statistics:\n" + f"- Total findings: {total}\n" + f"- Severity breakdown: {sev_summary if sev_summary else 'none recorded'}\n" + f"- Top vulnerability categories: {top_cats if top_cats else 'general'}\n" + f"- Most critical findings:\n{top_findings_text}\n\n" + "Write a concise 3-5 sentence executive summary suitable for " + "non-technical stakeholders. Focus on: overall risk posture, the most " + "important issues to address first, and one recommended next step. " + "Do not mention hostnames, IP addresses, or credentials. " + "Plain text only — no bullet points, no markdown." + ) + + +def generate_summary( + findings: list[dict], + model: str, + api_key: str, + base_url: Optional[str] = None, + timeout: float = 15.0, +) -> str: + """Generate an LLM executive summary from scan findings. + + Args: + findings: List of normalised finding dicts from a completed scan. + model: Model name e.g. ``"gpt-4o-mini"`` or ``"llama3"``. + api_key: API key for the OpenAI-compatible endpoint. + base_url: Optional base URL override for non-OpenAI providers. + timeout: HTTP timeout in seconds (default 15). Prevents LLM calls + from stalling report generation. + + Returns: + A plain-text executive summary string, or ``""`` on any failure so + that callers always get a safe value to embed in reports. + """ + if not findings: + return "" + + if OpenAI is None: + logger.warning( + "ai_summary: 'openai' package not installed. " + "Run `pip install openai>=1.0.0` to enable AI summaries." + ) + return "" + + prompt = _build_prompt(findings) + client_kwargs: dict = {"api_key": api_key, "timeout": timeout} + if base_url: + client_kwargs["base_url"] = base_url + + try: + client = OpenAI(**client_kwargs) + response = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + max_tokens=300, + temperature=0.4, + ) + summary = response.choices[0].message.content or "" + return summary.strip() + except Exception as exc: # noqa: BLE001 + logger.warning("ai_summary: LLM call failed — %s", exc) + return "" diff --git a/backend/secuscan/auth.py b/backend/secuscan/auth.py index 0cf70bb70..343f0e460 100644 --- a/backend/secuscan/auth.py +++ b/backend/secuscan/auth.py @@ -11,7 +11,7 @@ import secrets from pathlib import Path -from fastapi import Depends, HTTPException, Security, status +from fastapi import Depends, HTTPException, Security, status, Request from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer _bearer_scheme = HTTPBearer(auto_error=False) @@ -42,6 +42,7 @@ def init_api_key(data_dir: str) -> str: async def require_api_key( + request: Request = None, bearer: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), x_api_key: str | None = Security(_api_key_header), ) -> str: @@ -52,6 +53,11 @@ async def require_api_key( - ``Authorization: Bearer `` - ``X-Api-Key: `` """ + if request is not None and request.url.path.startswith("/api/v1/admin"): + # Admin endpoints have their own separate verify_admin_access dependency. + # We bypass require_api_key verification to avoid blocking valid admin key requests. + return "" + if _api_key is None: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, @@ -77,3 +83,41 @@ async def require_api_key( def get_api_key() -> str | None: """Return the current API key, or None if not yet initialised.""" return _api_key + + +# ── Per-user / per-workspace ownership ────────────────────────────────────── +# +# SecuScan authenticates the deployment with a single shared API key (above). +# That gate does not, by itself, distinguish between the different users or +# workspaces that share a deployment, which is what allowed any caller to read, +# delete, or export any task/report by guessing its ID (BOLA, issue #401). +# +# ``resolve_owner_id`` derives a stable owner identity for the request and is +# persisted as ``owner_id`` on tasks/findings/reports at creation time and +# compared on every read/delete/report access. It deliberately prioritises the +# explicit authenticated-user header (``X-User-Id``) — the same header +# ``resolve_client_identity`` already treats as the authenticated user — so that +# multiple workspaces sharing the deployment API key remain isolated. In a +# production deployment the header is expected to be set by an upstream auth +# proxy / SSO layer; deployments that do not send it fall back to a single +# shared ``DEFAULT_OWNER_ID`` and keep their existing (single-user) behaviour. +# +# This value is duplicated as the SQL column default ('default') in +# database.py — keep the two in sync. +DEFAULT_OWNER_ID = "default" + +_OWNER_HEADER = "x-user-id" + + +def resolve_owner_id(request: Request | None) -> str: + """Resolve the owning user/workspace identity for the current request.""" + if request is not None: + user_id = request.headers.get(_OWNER_HEADER) + if user_id and user_id.strip(): + return f"user:{user_id.strip()}" + return DEFAULT_OWNER_ID + + +async def get_current_owner(request: Request) -> str: + """FastAPI dependency yielding the owner identity for the request.""" + return resolve_owner_id(request) diff --git a/backend/secuscan/config.py b/backend/secuscan/config.py index 505d8e04f..35bcf2823 100644 --- a/backend/secuscan/config.py +++ b/backend/secuscan/config.py @@ -33,6 +33,7 @@ class Settings(BaseSettings): reports_dir: str = str(PROJECT_ROOT / "data" / "reports") plugins_dir: str = str(PROJECT_ROOT.parent / "plugins") wordlists_dir: str = str(PROJECT_ROOT / "wordlists") + knowledgebase_dir: str = str(PROJECT_ROOT / "data" / "knowledgebase") # Security safe_mode_default: bool = True @@ -59,6 +60,26 @@ class Settings(BaseSettings): enforce_plugin_signatures: bool = False vault_key: Optional[str] = None denied_capabilities: List[str] = [] + admin_api_key: Optional[str] = None + + # Network Policy Configuration + network_allowlist: List[str] = [] # IPs/networks to allow (CIDR); empty = deny all egress + network_denylist: List[str] = [ # IPs/networks to deny (CIDR) + "169.254.169.254/32", # AWS metadata + "169.254.0.0/16", # Reserved/metadata + "127.0.0.0/8", # Loopback (for remote execution) + "10.0.0.0/8", # Private RFC 1918 + "172.16.0.0/12", # Private RFC 1918 + "192.168.0.0/16", # Private RFC 1918 + "100.64.0.0/10", # Carrier-grade NAT (RFC 6598) + "fc00::/7", # IPv6 Unique Local Address + "fe80::/10", # IPv6 Link-local + "::1/128", # IPv6 Loopback + ] + network_audit_log_file: str = str(PROJECT_ROOT / "logs" / "network.audit.log") + network_audit_retention_days: int = 90 + enforce_network_policy: bool = True + network_policy_failure_mode: str = "block" # "block" or "log_only" # Rate Limiting max_concurrent_tasks: int = 3 @@ -78,6 +99,12 @@ class Settings(BaseSettings): rate_limit_read_heavy_limit: int = 100 rate_limit_read_heavy_window: int = 60 + # Scheduler tick: one trigger per 10 seconds allows legitimate external + # callers while preventing a tight loop from forcing continuous workflow + # execution and exhausting scan quotas. + rate_limit_scheduler_tick_limit: int = 1 + rate_limit_scheduler_tick_window: int = 10 + trusted_proxies: List[str] = ["127.0.0.1", "::1"] # Sandbox @@ -85,6 +112,9 @@ class Settings(BaseSettings): sandbox_timeout: int = 600 # seconds sandbox_cpu_quota: float = 0.5 sandbox_memory_mb: int = 512 + sandbox_max_output_bytes: int = 5_242_880 # 5 MB + sandbox_allow_network: bool = True + docker_network: str = "restricted" # Docker network name for sandboxed containers # Task-start payload limits (tunable via env vars) task_start_max_body_bytes: int = 64_000 # 64 KB total JSON body @@ -95,15 +125,63 @@ class Settings(BaseSettings): parser_sandbox_timeout_seconds: int = 30 parser_sandbox_max_output_bytes: int = 8 * 1024 * 1024 # 8 MB + # Workflow Configuration + workflow_min_interval_seconds: int = 60 + + # Notification SSRF Protection + notification_ssrf_enabled: bool = True + notification_allowed_ip_ranges: List[str] = [] + notification_blocked_ip_ranges: List[str] = [ + "169.254.169.254/32", + "169.254.0.0/16", + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "100.64.0.0/10", + "fc00::/7", + "fe80::/10", + "::1/128", + "224.0.0.0/4", + "ff00::/8", + "0.0.0.0/8", + ] + notification_max_redirects: int = 0 + notification_allowed_ports: List[int] = [80, 443, 8080, 8443] + # Logging log_level: str = "INFO" log_file: str = str(PROJECT_ROOT / "logs" / "secuscan.log") + # AI Executive Summary (opt-in — feature off by default) + ai_summary_enabled: bool = False + ai_summary_api_key: str = "" + ai_summary_base_url: str = "" + ai_summary_model: str = "gpt-4o-mini" + + # SMTP Configuration for Email Notifications + smtp_host: str = "smtp.gmail.com" + smtp_port: int = 587 + smtp_username: Optional[str] = None + smtp_password: Optional[str] = None + smtp_from_email: str = "noreply@secuscan.io" + smtp_use_tls: bool = True + class Config: env_prefix = "SECUSCAN_" case_sensitive = False - @field_validator("cors_allowed_origins", "cors_allowed_methods", "cors_allowed_headers", "trusted_proxies", mode="before") + @field_validator( + "cors_allowed_origins", + "cors_allowed_methods", + "cors_allowed_headers", + "trusted_proxies", + "network_allowlist", + "network_denylist", + "notification_allowed_ip_ranges", + "notification_blocked_ip_ranges", + mode="before", + ) @classmethod def parse_csv_or_list(cls, value: Any) -> Any: """Allow comma-separated env values in addition to JSON arrays.""" @@ -141,6 +219,7 @@ def ensure_directories(self) -> None: self.raw_output_dir, self.reports_dir, self.wordlists_dir, + self.knowledgebase_dir, Path(self.log_file).parent, ]: Path(directory).mkdir(parents=True, exist_ok=True) @@ -149,6 +228,5 @@ def ensure_directories(self) -> None: (Path(self.raw_output_dir) / ".gitkeep").touch() (Path(self.reports_dir) / ".gitkeep").touch() - # Global settings instance settings = Settings() diff --git a/backend/secuscan/crawler.py b/backend/secuscan/crawler.py new file mode 100644 index 000000000..183a50d30 --- /dev/null +++ b/backend/secuscan/crawler.py @@ -0,0 +1,240 @@ +"""Lightweight authenticated crawl helpers for modular scanners.""" + +from __future__ import annotations + +from html.parser import HTMLParser +import re +from typing import Any, Dict, List +from urllib.parse import parse_qsl, urljoin, urlparse + +import httpx + + +class _SurfaceParser(HTMLParser): + def __init__(self) -> None: + super().__init__() + self.links: List[str] = [] + self.forms: List[Dict[str, Any]] = [] + self.scripts: List[str] = [] + self.meta_generators: List[str] = [] + self._current_form: Dict[str, Any] | None = None + + def handle_starttag(self, tag: str, attrs: List[tuple[str, str | None]]) -> None: + attrs_dict = {key.lower(): value or "" for key, value in attrs} + if tag == "a" and attrs_dict.get("href"): + self.links.append(attrs_dict["href"]) + elif tag == "script" and attrs_dict.get("src"): + self.scripts.append(attrs_dict["src"]) + elif tag == "meta": + meta_name = attrs_dict.get("name", "").lower() + if meta_name == "generator" and attrs_dict.get("content"): + self.meta_generators.append(attrs_dict["content"]) + elif tag == "form": + self._current_form = { + "action": attrs_dict.get("action", ""), + "method": attrs_dict.get("method", "get").lower(), + "inputs": [], + "id": attrs_dict.get("id", ""), + "name": attrs_dict.get("name", ""), + } + self.forms.append(self._current_form) + elif tag == "input" and self._current_form is not None: + self._current_form["inputs"].append( + { + "name": attrs_dict.get("name", ""), + "type": attrs_dict.get("type", "text"), + "value": attrs_dict.get("value", ""), + } + ) + elif tag in {"textarea", "select"} and self._current_form is not None: + self._current_form["inputs"].append( + { + "name": attrs_dict.get("name", ""), + "type": tag, + "value": "", + } + ) + + def handle_endtag(self, tag: str) -> None: + if tag == "form": + self._current_form = None + + +def _build_headers(extra_headers: Dict[str, Any] | None = None) -> Dict[str, str]: + headers: Dict[str, str] = { + "User-Agent": "SecuScan-Crawler/1.0", + "Accept": "text/html,application/json;q=0.9,*/*;q=0.8", + } + if extra_headers: + for key, value in extra_headers.items(): + if key and value is not None: + headers[str(key)] = str(value) + return headers + + +async def crawl_target( + url: str, + *, + timeout: int = 10, + cookies: Dict[str, str] | None = None, + extra_headers: Dict[str, Any] | None = None, +) -> Dict[str, Any]: + """Fetch a target and normalize discovered links/forms/scripts/API hints.""" + headers = _build_headers(extra_headers) + async with httpx.AsyncClient( + follow_redirects=True, + timeout=timeout, + headers=headers, + cookies=cookies or {}, + verify=False, + ) as client: + response = await client.get(url) + + body = response.text + parser = _SurfaceParser() + parser.feed(body) + + base_url = str(response.url) + final_parsed = urlparse(base_url) + normalized_links = sorted({urljoin(base_url, link) for link in parser.links if link}) + normalized_scripts = sorted({urljoin(base_url, script) for script in parser.scripts if script}) + + params = [] + for parsed_link in normalized_links: + for key, value in parse_qsl(urlparse(parsed_link).query, keep_blank_values=True): + params.append({"url": parsed_link, "name": key, "sample": value}) + + api_hints = [] + path_hints = [] + for candidate in normalized_links + normalized_scripts: + lowered = candidate.lower() + if any(token in lowered for token in ("/api/", "swagger", "openapi", "graphql", ".json")): + api_hints.append(candidate) + path_tag = _classify_path_hint(lowered) + if path_tag: + path_hints.append({"url": candidate, "kind": path_tag}) + + forms = [_normalize_form(base_url, form) for form in parser.forms[:50]] + headers_snapshot = dict(response.headers) + set_cookie_headers = list(response.headers.get_list("set-cookie")) if hasattr(response.headers, "get_list") else [] + tech_hints = _extract_tech_hints(headers_snapshot, parser.meta_generators, normalized_scripts, body) + cms_hints = _extract_cms_hints(parser.meta_generators, body, normalized_scripts) + redirect_chain = [ + { + "url": str(item.url), + "status_code": item.status_code, + "location": item.headers.get("location"), + } + for item in response.history + ] + + return { + "seed_url": url, + "final_url": base_url, + "status_code": response.status_code, + "scheme": final_parsed.scheme, + "headers": headers_snapshot, + "set_cookie_headers": set_cookie_headers[:20], + "redirect_chain": redirect_chain[:10], + "tech_hints": tech_hints[:20], + "cms_hints": cms_hints[:10], + "pages": [{"url": base_url, "title": _extract_title(body), "content_type": response.headers.get("content-type", "")}] + [ + {"url": link, "title": "", "content_type": ""} for link in normalized_links[:100] + ], + "forms": forms, + "scripts": normalized_scripts[:100], + "params": params[:200], + "api_hints": sorted(set(api_hints))[:100], + "path_hints": path_hints[:100], + "body_preview": body[:4000], + } + + +def _extract_title(html: str) -> str: + start = html.lower().find("") + end = html.lower().find("") + if start == -1 or end == -1 or end <= start: + return "" + return html[start + len(""):end].strip() + + +def _normalize_form(page_url: str, form: Dict[str, Any]) -> Dict[str, Any]: + inputs = form.get("inputs", []) if isinstance(form.get("inputs"), list) else [] + method = str(form.get("method") or "get").lower() + action = urljoin(page_url, str(form.get("action") or "")) + state_changing = method in {"post", "put", "patch", "delete"} or any( + str(item.get("type") or "").lower() in {"password", "file", "hidden"} + for item in inputs + if isinstance(item, dict) + ) + csrf_names = {"csrf", "_csrf", "csrfmiddlewaretoken", "authenticity_token", "__requestverificationtoken"} + has_csrf_token = any( + str(item.get("name") or "").strip().lower() in csrf_names + for item in inputs + if isinstance(item, dict) + ) + password_fields = sum( + 1 + for item in inputs + if isinstance(item, dict) and str(item.get("type") or "").lower() == "password" + ) + return { + **form, + "page_url": page_url, + "action": action, + "state_changing": state_changing, + "has_csrf_token": has_csrf_token, + "password_fields": password_fields, + "input_count": len(inputs), + } + + +def _classify_path_hint(value: str) -> str | None: + patterns = { + "admin": ("/admin", "/administrator", "/wp-admin"), + "login": ("/login", "/signin", "/auth", "/user/login"), + "debug": ("/debug", "/console", "/actuator", "/_profiler"), + "docs": ("/docs", "/swagger", "/openapi", "/redoc"), + } + for label, tokens in patterns.items(): + if any(token in value for token in tokens): + return label + return None + + +def _extract_tech_hints( + headers: Dict[str, str], + meta_generators: List[str], + scripts: List[str], + body: str, +) -> List[str]: + hints: List[str] = [] + for key in ("server", "x-powered-by", "x-generator"): + value = headers.get(key) or headers.get(key.title()) + if value: + hints.append(str(value)) + hints.extend(meta_generators) + body_lower = body.lower() + if "wp-content" in body_lower: + hints.append("WordPress") + if "/sites/default/" in body_lower: + hints.append("Drupal") + if "joomla!" in body_lower or "/media/system/js/" in body_lower: + hints.append("Joomla") + for script in scripts: + lowered = script.lower() + if any(token in lowered for token in ("react", "vue", "angular", "jquery", "bootstrap")): + hints.append(script.rsplit("/", 1)[-1]) + return sorted({item.strip() for item in hints if str(item).strip()}) + + +def _extract_cms_hints(meta_generators: List[str], body: str, scripts: List[str]) -> List[str]: + hints: List[str] = [] + combined = " ".join(meta_generators).lower() + if "wordpress" in combined or "wp-content" in body.lower(): + hints.append("wordpress") + if "drupal" in combined or "/sites/default/" in body.lower(): + hints.append("drupal") + if "joomla" in combined or any("/media/system/js/" in script.lower() for script in scripts): + hints.append("joomla") + return sorted(set(hints)) diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index 69c66b2fe..ee9b74e54 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -54,12 +54,15 @@ async def _create_schema(self): """ CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT 'default', plugin_id TEXT NOT NULL, tool_name TEXT NOT NULL, target TEXT NOT NULL, inputs_json TEXT NOT NULL DEFAULT '{}', + execution_context_json TEXT NOT NULL DEFAULT '{}', preset TEXT, status TEXT NOT NULL DEFAULT 'queued', + scan_phase TEXT, consent_granted BOOLEAN NOT NULL DEFAULT 0, safe_mode BOOLEAN NOT NULL DEFAULT 1, created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), @@ -95,6 +98,7 @@ async def _create_schema(self): CREATE TABLE IF NOT EXISTS findings ( id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT 'default', task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, plugin_id TEXT NOT NULL, title TEXT NOT NULL, @@ -106,6 +110,29 @@ async def _create_schema(self): proof TEXT, cvss REAL, cve TEXT, + exploitability REAL, + confidence REAL, + validated BOOLEAN NOT NULL DEFAULT 0, + validation_method TEXT, + confidence_reason TEXT, + finding_kind TEXT NOT NULL DEFAULT 'observation', + finding_group_id TEXT, + asset_id TEXT, + first_seen_at TIMESTAMP, + last_seen_at TIMESTAMP, + occurrence_count INTEGER NOT NULL DEFAULT 1, + corroborating_sources_json TEXT NOT NULL DEFAULT '[]', + evidence_count INTEGER NOT NULL DEFAULT 0, + analyst_status TEXT NOT NULL DEFAULT 'new', + retest_status TEXT NOT NULL DEFAULT 'not_requested', + evidence_json TEXT NOT NULL DEFAULT '[]', + asset_refs_json TEXT NOT NULL DEFAULT '[]', + service_fingerprint TEXT, + cpe TEXT, + references_json TEXT NOT NULL DEFAULT '[]', + asset_exposure TEXT, + risk_score REAL, + risk_factors_json TEXT NOT NULL DEFAULT '[]', discovered_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), metadata_json TEXT NOT NULL DEFAULT '{}' ); @@ -113,6 +140,7 @@ async def _create_schema(self): CREATE TABLE IF NOT EXISTS reports ( id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT 'default', task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, name TEXT NOT NULL, type TEXT NOT NULL DEFAULT 'technical', @@ -123,6 +151,87 @@ async def _create_schema(self): file_path TEXT ); + CREATE TABLE IF NOT EXISTS target_policies ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + description TEXT, + allow_public_targets BOOLEAN NOT NULL DEFAULT 0, + allow_exploit_validation BOOLEAN NOT NULL DEFAULT 0, + allow_authenticated_scan BOOLEAN NOT NULL DEFAULT 0, + default_validation_mode TEXT NOT NULL DEFAULT 'proof', + allowed_targets_json TEXT NOT NULL DEFAULT '[]', + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS credential_profiles ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + username_secret_name TEXT, + password_secret_name TEXT, + extra_headers_json TEXT NOT NULL DEFAULT '{}', + login_recipe_json TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS session_profiles ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT 'default', + name TEXT NOT NULL, + cookie_secret_name TEXT, + extra_headers_json TEXT NOT NULL DEFAULT '{}', + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS crawl_runs ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT 'default', + task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, + plugin_id TEXT NOT NULL, + target TEXT NOT NULL, + seed_url TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'completed', + summary_json TEXT NOT NULL DEFAULT '{}', + pages_json TEXT NOT NULL DEFAULT '[]', + forms_json TEXT NOT NULL DEFAULT '[]', + scripts_json TEXT NOT NULL DEFAULT '[]', + params_json TEXT NOT NULL DEFAULT '[]', + api_hints_json TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS asset_services ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT 'default', + task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, + plugin_id TEXT NOT NULL, + target TEXT NOT NULL, + asset_id TEXT, + host TEXT NOT NULL, + ip TEXT, + port INTEGER, + protocol TEXT, + service TEXT, + product TEXT, + version TEXT, + cpe TEXT, + confidence REAL, + title TEXT, + banner TEXT, + cert_subject TEXT, + cert_san_json TEXT NOT NULL DEFAULT '[]', + cert_expiry TEXT, + service_fingerprint TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -165,17 +274,49 @@ async def _create_schema(self): CREATE TABLE IF NOT EXISTS workflows ( id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + owner_id TEXT NOT NULL DEFAULT 'default', schedule_seconds INTEGER, enabled BOOLEAN NOT NULL DEFAULT 1, steps_json TEXT NOT NULL DEFAULT '[]', created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), - last_run_at TIMESTAMP + last_run_at TIMESTAMP, + UNIQUE(owner_id, name) + ); + + CREATE TABLE IF NOT EXISTS workflow_versions ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + definition_json TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + created_by TEXT NOT NULL DEFAULT 'system', + UNIQUE(workflow_id, version_number) + ); + + CREATE INDEX IF NOT EXISTS idx_wf_versions_workflow ON workflow_versions(workflow_id, version_number DESC); + + CREATE TABLE IF NOT EXISTS workflow_runs ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + version_id TEXT REFERENCES workflow_versions(id) ON DELETE SET NULL, + version_number INTEGER, + triggered_by TEXT NOT NULL DEFAULT 'manual', + status TEXT NOT NULL DEFAULT 'queued', + task_ids_json TEXT NOT NULL DEFAULT '[]', + started_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + completed_at TIMESTAMP, + error_message TEXT ); + CREATE INDEX IF NOT EXISTS idx_wf_runs_workflow ON workflow_runs(workflow_id, started_at DESC); + CREATE INDEX IF NOT EXISTS idx_wf_runs_status ON workflow_runs(status); + CREATE INDEX IF NOT EXISTS idx_wf_runs_version_id ON workflow_runs(version_id); + CREATE TABLE IF NOT EXISTS notification_rules ( id TEXT PRIMARY KEY, name TEXT NOT NULL, + owner_id TEXT NOT NULL DEFAULT 'default', severity_threshold TEXT NOT NULL, channel_type TEXT NOT NULL, target_url_or_email TEXT NOT NULL, @@ -200,6 +341,9 @@ async def _create_schema(self): CREATE INDEX IF NOT EXISTS idx_tasks_plugin ON tasks(plugin_id); -- Composite index for dashboard running tasks query CREATE INDEX IF NOT EXISTS idx_tasks_status_created ON tasks(status, created_at DESC); + -- Owner scoping (BOLA prevention, issue #401) + CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner_id); + CREATE INDEX IF NOT EXISTS idx_tasks_execution_context ON tasks(owner_id, plugin_id, created_at DESC); -- Findings indexes (new) CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity); @@ -209,6 +353,12 @@ async def _create_schema(self): CREATE INDEX IF NOT EXISTS idx_findings_target ON findings(target); -- Composite index for severity counting by task CREATE INDEX IF NOT EXISTS idx_findings_task_severity ON findings(task_id, severity); + -- Owner scoping (BOLA prevention, issue #401) + CREATE INDEX IF NOT EXISTS idx_findings_owner ON findings(owner_id); + CREATE INDEX IF NOT EXISTS idx_findings_cpe ON findings(cpe); + CREATE INDEX IF NOT EXISTS idx_findings_validated ON findings(validated); + CREATE INDEX IF NOT EXISTS idx_findings_group_id ON findings(owner_id, finding_group_id); + CREATE INDEX IF NOT EXISTS idx_findings_asset_id ON findings(owner_id, asset_id); -- Reports indexes (new) CREATE INDEX IF NOT EXISTS idx_reports_task_id ON reports(task_id); @@ -227,6 +377,14 @@ async def _create_schema(self): CREATE INDEX IF NOT EXISTS idx_notification_history_rule_id ON notification_history(rule_id); CREATE INDEX IF NOT EXISTS idx_notification_history_finding_id ON notification_history(finding_id); CREATE INDEX IF NOT EXISTS idx_notification_history_sent_at ON notification_history(sent_at DESC); + CREATE INDEX IF NOT EXISTS idx_target_policies_owner ON target_policies(owner_id); + CREATE INDEX IF NOT EXISTS idx_credential_profiles_owner ON credential_profiles(owner_id); + CREATE INDEX IF NOT EXISTS idx_session_profiles_owner ON session_profiles(owner_id); + CREATE INDEX IF NOT EXISTS idx_crawl_runs_task_id ON crawl_runs(task_id); + CREATE INDEX IF NOT EXISTS idx_crawl_runs_owner_created ON crawl_runs(owner_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_asset_services_task_id ON asset_services(task_id); + CREATE INDEX IF NOT EXISTS idx_asset_services_owner_created ON asset_services(owner_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_asset_services_asset_id ON asset_services(owner_id, asset_id); """ ) @@ -235,6 +393,10 @@ async def _create_schema(self): existing_cols = {col["name"] for col in tasks_columns} needed_cols = { + # Per-user ownership for BOLA prevention (issue #401). NOT NULL with a + # constant default backfills every existing row to the shared default + # owner, preserving single-user deployments' access to their history. + "owner_id": "TEXT NOT NULL DEFAULT 'default'", "exit_code": "INTEGER", "scan_phase": "TEXT", "structured_json": "TEXT", @@ -245,6 +407,7 @@ async def _create_schema(self): "cpu_seconds": "REAL", "memory_peak_mb": "REAL", "inputs_json": "TEXT NOT NULL DEFAULT '{}'", + "execution_context_json": "TEXT NOT NULL DEFAULT '{}'", "preset": "TEXT", "safe_mode": "BOOLEAN NOT NULL DEFAULT 1" } @@ -269,9 +432,29 @@ async def _create_schema(self): risk_cols = { "exploitability": "REAL", "confidence": "REAL", + "validated": "BOOLEAN NOT NULL DEFAULT 0", + "validation_method": "TEXT", + "confidence_reason": "TEXT", + "finding_kind": "TEXT NOT NULL DEFAULT 'observation'", + "finding_group_id": "TEXT", + "asset_id": "TEXT", + "first_seen_at": "TIMESTAMP", + "last_seen_at": "TIMESTAMP", + "occurrence_count": "INTEGER NOT NULL DEFAULT 1", + "corroborating_sources_json": "TEXT NOT NULL DEFAULT '[]'", + "evidence_count": "INTEGER NOT NULL DEFAULT 0", + "analyst_status": "TEXT NOT NULL DEFAULT 'new'", + "retest_status": "TEXT NOT NULL DEFAULT 'not_requested'", + "evidence_json": "TEXT NOT NULL DEFAULT '[]'", + "asset_refs_json": "TEXT NOT NULL DEFAULT '[]'", + "service_fingerprint": "TEXT", + "cpe": "TEXT", + "references_json": "TEXT NOT NULL DEFAULT '[]'", "asset_exposure": "TEXT", "risk_score": "REAL", "risk_factors_json": "TEXT NOT NULL DEFAULT '[]'", + # Per-user ownership for BOLA prevention (issue #401). + "owner_id": "TEXT NOT NULL DEFAULT 'default'", } for col_name, col_type in risk_cols.items(): if col_name not in existing_finding_cols: @@ -281,6 +464,117 @@ async def _create_schema(self): except Exception as e: print(f"Failed to add column {col_name}: {e}") + asset_service_columns = await self.fetchall("PRAGMA table_info(asset_services)") + existing_asset_service_cols = {col["name"] for col in asset_service_columns} + asset_service_needed = { + "asset_id": "TEXT", + "ip": "TEXT", + "title": "TEXT", + "banner": "TEXT", + "cert_subject": "TEXT", + "cert_san_json": "TEXT NOT NULL DEFAULT '[]'", + "cert_expiry": "TEXT", + "service_fingerprint": "TEXT", + } + for col_name, col_type in asset_service_needed.items(): + if col_name not in existing_asset_service_cols: + try: + await self.execute(f"ALTER TABLE asset_services ADD COLUMN {col_name} {col_type}") + print(f"Added missing column {col_name} to asset_services table.") + except Exception as e: + print(f"Failed to add column {col_name} to asset_services: {e}") + + # Reports table migration: ensure owner_id exists (issue #401) + reports_columns = await self.fetchall("PRAGMA table_info(reports)") + existing_report_cols = {col["name"] for col in reports_columns} + if "owner_id" not in existing_report_cols: + try: + await self.execute( + "ALTER TABLE reports ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'default'" + ) + print("Added missing column 'owner_id' to reports table.") + except Exception as e: + print(f"Failed to add 'owner_id' to reports: {e}") + + # Workflows table migration: ensure owner_id and composite unique exist + workflows_columns = await self.fetchall("PRAGMA table_info(workflows)") + existing_wf_cols = {col["name"] for col in workflows_columns} + if "owner_id" not in existing_wf_cols: + try: + await self.execute( + "ALTER TABLE workflows ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'default'" + ) + print("Added missing column 'owner_id' to workflows table.") + except Exception as e: + print(f"Failed to add 'owner_id' to workflows: {e}") + + # On legacy databases the old CREATE TABLE had UNIQUE on name alone, + # which blocks same-named workflows across owners. SQLite cannot + # ALTER TABLE … DROP CONSTRAINT, so we must recreate the table. + wf_schema = await self.fetchone( + "SELECT sql FROM sqlite_master WHERE type='table' AND name='workflows'" + ) + if wf_schema and "owner_id" in existing_wf_cols: + ddl = wf_schema["sql"] + # Check for the old inline UNIQUE constraint on name + has_old_unique = "name TEXT NOT NULL UNIQUE" in ddl + has_composite = "UNIQUE(owner_id, name)" in ddl + if has_old_unique or not has_composite: + old_fk = await self.fetchone("PRAGMA foreign_keys") + if old_fk: + await self.execute("PRAGMA foreign_keys = OFF") + try: + await self.connection.executescript(""" + CREATE TABLE workflows_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_id TEXT NOT NULL DEFAULT 'default', + schedule_seconds INTEGER, + enabled BOOLEAN NOT NULL DEFAULT 1, + steps_json TEXT NOT NULL DEFAULT '[]', + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + last_run_at TIMESTAMP, + UNIQUE(owner_id, name) + ); + INSERT INTO workflows_new + (id, name, owner_id, schedule_seconds, enabled, + steps_json, created_at, last_run_at) + SELECT + id, name, COALESCE(owner_id, 'default'), + schedule_seconds, enabled, steps_json, created_at, last_run_at + FROM workflows; + DROP TABLE workflows; + ALTER TABLE workflows_new RENAME TO workflows; + """) + await self.connection.commit() + print("Replaced workflows UNIQUE(name) constraint with UNIQUE(owner_id, name).") + finally: + if old_fk: + await self.execute("PRAGMA foreign_keys = ON") + + # Notification rules table migration: ensure owner_id exists + notif_columns = await self.fetchall("PRAGMA table_info(notification_rules)") + existing_notif_cols = {col["name"] for col in notif_columns} + if "owner_id" not in existing_notif_cols: + try: + await self.execute( + "ALTER TABLE notification_rules ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'default'" + ) + print("Added missing column 'owner_id' to notification_rules table.") + except Exception as e: + print(f"Failed to add 'owner_id' to notification_rules: {e}") + + # Owner indexes must run after ALTER TABLE backfills owner_id on legacy DBs. + await self.connection.executescript( + """ + CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner_id); + CREATE INDEX IF NOT EXISTS idx_findings_owner ON findings(owner_id); + CREATE INDEX IF NOT EXISTS idx_reports_owner ON reports(owner_id); + CREATE INDEX IF NOT EXISTS idx_workflows_owner ON workflows(owner_id); + CREATE INDEX IF NOT EXISTS idx_notification_rules_owner ON notification_rules(owner_id); + """ + ) + async def _run_migrations(self): migrations_dir = Path(__file__).parent / "migrations" @@ -338,9 +632,27 @@ async def _backfill_risk_scores(self): print(f"Backfilled risk scores for {len(rows)} existing finding(s).") async def execute(self, query: str, params: tuple = ()): - """Execute a write query.""" - await self.connection.execute(query, params) + """Execute a write query and return the cursor (so callers can inspect rowcount).""" + cursor = await self.connection.execute(query, params) await self.connection.commit() + return cursor + + async def execute_no_commit(self, query: str, params: tuple = ()): + """Execute a write query without committing (for use inside transactions).""" + cursor = await self.connection.execute(query, params) + return cursor + + async def begin(self): + """Begin a transaction.""" + await self.connection.execute("BEGIN") + + async def commit(self): + """Commit the current transaction.""" + await self.connection.commit() + + async def rollback(self): + """Roll back the current transaction.""" + await self.connection.rollback() async def fetchone(self, query: str, params: tuple = ()) -> Optional[Dict]: """Fetch one row.""" @@ -401,6 +713,187 @@ async def log_audit( ) + async def snapshot_workflow_version( + self, + workflow_id: str, + name: str, + schedule_seconds: Optional[int], + enabled: bool, + steps: List[Dict], + created_by: str = "system", + ) -> Dict: + """Snapshot the current workflow definition as a new version row. + + Returns the new version record including its auto-assigned version_number. + The version_number is the next integer in the per-workflow sequence. + """ + version_id = json.dumps(None) + row = await self.fetchone( + "SELECT MAX(version_number) AS max_v FROM workflow_versions WHERE workflow_id = ?", + (workflow_id,), + ) + next_version = (row["max_v"] or 0) + 1 if row else 1 + version_id = __import__("uuid").uuid4().hex + definition = { + "name": name, + "schedule_seconds": schedule_seconds, + "enabled": enabled, + "steps": steps, + } + await self.execute( + "INSERT INTO workflow_versions (id, workflow_id, version_number, definition_json, created_by) " + "VALUES (?, ?, ?, ?, ?)", + (version_id, workflow_id, next_version, json.dumps(definition), created_by), + ) + return { + "id": version_id, + "workflow_id": workflow_id, + "version_number": next_version, + "definition": definition, + "created_by": created_by, + } + + async def get_workflow_versions(self, workflow_id: str) -> List[Dict]: + """Return all versions for a workflow ordered newest first.""" + rows = await self.fetchall( + "SELECT id, workflow_id, version_number, definition_json, created_at, created_by " + "FROM workflow_versions WHERE workflow_id = ? ORDER BY version_number DESC", + (workflow_id,), + ) + result = [] + for row in rows: + try: + defn = json.loads(row["definition_json"]) + except (json.JSONDecodeError, TypeError): + defn = {} + result.append({ + "id": row["id"], + "workflow_id": row["workflow_id"], + "version_number": row["version_number"], + "definition": defn, + "created_at": row["created_at"], + "created_by": row["created_by"], + }) + return result + + async def get_workflow_version(self, workflow_id: str, version_number: int) -> Optional[Dict]: + """Return a specific version record or None if it does not exist.""" + row = await self.fetchone( + "SELECT id, workflow_id, version_number, definition_json, created_at, created_by " + "FROM workflow_versions WHERE workflow_id = ? AND version_number = ?", + (workflow_id, version_number), + ) + if row is None: + return None + try: + defn = json.loads(row["definition_json"]) + except (json.JSONDecodeError, TypeError): + defn = {} + return { + "id": row["id"], + "workflow_id": row["workflow_id"], + "version_number": row["version_number"], + "definition": defn, + "created_at": row["created_at"], + "created_by": row["created_by"], + } + + async def record_workflow_run( + self, + workflow_id: str, + version_id: Optional[str], + version_number: Optional[int], + task_ids: List[str], + triggered_by: str = "manual", + ) -> str: + """Insert a workflow run record and return the run ID.""" + run_id = __import__("uuid").uuid4().hex + await self.execute( + "INSERT INTO workflow_runs " + "(id, workflow_id, version_id, version_number, triggered_by, status, task_ids_json) " + "VALUES (?, ?, ?, ?, ?, 'queued', ?)", + (run_id, workflow_id, version_id, version_number, triggered_by, json.dumps(task_ids)), + ) + return run_id + + async def finalize_workflow_run(self, run_id: str, status: str, error_message: Optional[str] = None) -> None: + """Mark a workflow run as completed, failed, or cancelled with a timestamp. + + status must be one of: completed | failed | cancelled. + This is called once all tasks in the run reach a terminal state. + """ + await self.execute( + "UPDATE workflow_runs SET status = ?, completed_at = datetime('now'), error_message = ? " + "WHERE id = ?", + (status, error_message, run_id), + ) + + async def check_workflow_run_tasks(self, run_id: str) -> Optional[str]: + """Inspect the task statuses for a run and return the appropriate run status. + + Returns: + 'completed' if all tasks are completed. + 'failed' if any task failed and none are still running/queued. + 'cancelled' if any task was cancelled and none are still running/queued. + None if tasks are still in progress. + """ + run_row = await self.fetchone("SELECT task_ids_json FROM workflow_runs WHERE id = ?", (run_id,)) + if run_row is None: + return None + try: + task_ids = json.loads(run_row["task_ids_json"] or "[]") + except (json.JSONDecodeError, TypeError): + task_ids = [] + if not task_ids: + return "completed" + statuses = [] + for tid in task_ids: + row = await self.fetchone("SELECT status FROM tasks WHERE id = ?", (tid,)) + if row: + statuses.append(row["status"]) + if not statuses: + return None + in_progress = {"queued", "running"} + if any(s in in_progress for s in statuses): + return None + if all(s == "completed" for s in statuses): + return "completed" + if any(s == "cancelled" for s in statuses): + return "cancelled" + return "failed" + + async def get_workflow_runs(self, workflow_id: str, limit: int = 50, offset: int = 0) -> Dict: + """Return paginated run history for a workflow.""" + count_row = await self.fetchone( + "SELECT COUNT(*) AS total FROM workflow_runs WHERE workflow_id = ?", (workflow_id,) + ) + total = count_row["total"] if count_row else 0 + rows = await self.fetchall( + "SELECT * FROM workflow_runs WHERE workflow_id = ? " + "ORDER BY started_at DESC LIMIT ? OFFSET ?", + (workflow_id, limit, offset), + ) + entries = [] + for row in rows: + try: + task_ids = json.loads(row["task_ids_json"] or "[]") + except (json.JSONDecodeError, TypeError): + task_ids = [] + entries.append({ + "id": row["id"], + "workflow_id": row["workflow_id"], + "version_id": row["version_id"], + "version_number": row["version_number"], + "triggered_by": row["triggered_by"], + "status": row["status"], + "task_ids": task_ids, + "started_at": row["started_at"], + "completed_at": row["completed_at"], + "error_message": row["error_message"], + }) + return {"total": total, "runs": entries} + + db: Optional[Database] = None @@ -422,4 +915,4 @@ async def get_db() -> Database: if db is None: raise RuntimeError("Database not initialized") - return db \ No newline at end of file + return db diff --git a/backend/secuscan/execution_context.py b/backend/secuscan/execution_context.py new file mode 100644 index 000000000..b42d661d5 --- /dev/null +++ b/backend/secuscan/execution_context.py @@ -0,0 +1,35 @@ +"""Execution-context defaults and policy helpers.""" + +from __future__ import annotations + +from typing import Any, Dict + +from .models import EvidenceLevel, ExecutionContext, ValidationMode + + +def normalize_execution_context(raw: Any) -> Dict[str, Any]: + """Return a validated execution-context payload as a plain dict.""" + if isinstance(raw, ExecutionContext): + return raw.model_dump(mode="json") + if isinstance(raw, dict): + return ExecutionContext(**raw).model_dump(mode="json") + return ExecutionContext().model_dump(mode="json") + + +def is_offensive_validation(context: Dict[str, Any]) -> bool: + """True when validation mode goes beyond detect-only.""" + mode = str(context.get("validation_mode") or ValidationMode.PROOF.value) + return mode in { + ValidationMode.PROOF.value, + ValidationMode.CONTROLLED_EXTRACT.value, + } + + +def evidence_level_rank(level: str) -> int: + """Comparable evidence-level rank.""" + ordering = { + EvidenceLevel.MINIMAL.value: 0, + EvidenceLevel.STANDARD.value: 1, + EvidenceLevel.FULL.value: 2, + } + return ordering.get(level, 1) diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index 3dda63ebc..db078a8eb 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -4,6 +4,9 @@ import asyncio from asyncio import subprocess +import os +import signal +import base64 import uuid import json import time @@ -13,16 +16,75 @@ import logging import re +_CANCEL_GRACE_SECONDS = 5 + +from .auth import DEFAULT_OWNER_ID from .redaction import redact from .cache import get_cache from .config import settings from .database import get_db from .plugins import get_plugin_manager -from .models import TaskStatus, ScanPhase +from .models import NotificationDeliveryStatus, TaskStatus, ScanPhase from .ratelimit import concurrent_limiter from .risk_scoring import compute_risk_score, compute_risk_factors from .capabilities import CapabilityEnforcer, CapabilityDeniedError, build_enforcer_from_settings from .parser_sandbox import run_parser_in_sandbox, ParserSandboxError +from .network_policy import get_policy_engine +from .notification_service import process_task_notifications +from .execution_context import is_offensive_validation, normalize_execution_context +from .finding_intelligence import ( + build_asset_summary, + build_finding_groups, + build_scan_diff, + normalize_and_correlate_findings, +) +from .platform_resources import ( + get_credential_profile, + get_session_profile, + get_target_policy, + persist_crawl_run, + replace_asset_services, + serialize_execution_context, +) +from .vault import VaultCrypto + +async def _terminate_process_group(pid: int, task_id: str, grace_seconds: int = _CANCEL_GRACE_SECONDS) -> None: + """Send SIGTERM to the process group of *pid*, wait *grace_seconds*, then SIGKILL. + + Using a process group (via start_new_session=True on subprocess creation) + ensures every child and grandchild spawned by the scanner receives the + signal, leaving no orphan processes after cancellation or timeout. + + Errors are logged but never re-raised so callers can always proceed to + update task status regardless of OS-level kill failures. + """ + try: + pgid = os.getpgid(pid) + except (ProcessLookupError, PermissionError) as exc: + logger.debug("process group for pid %d already gone: %s", pid, exc) + return + + try: + os.killpg(pgid, signal.SIGTERM) + except (ProcessLookupError, PermissionError) as exc: + logger.debug("SIGTERM to pgid %d failed (already exited?): %s", pgid, exc) + return + + for _ in range(grace_seconds * 10): + await asyncio.sleep(0.1) + try: + os.killpg(pgid, 0) + except (ProcessLookupError, PermissionError): + return + + try: + os.killpg(pgid, signal.SIGKILL) + logger.warning( + "process group %d did not exit within %ds grace — SIGKILL sent (task %s)", + pgid, grace_seconds, task_id, + ) + except (ProcessLookupError, PermissionError) as exc: + logger.debug("SIGKILL to pgid %d failed: %s", pgid, exc) def _parse_discovered_at(finding: dict) -> Optional[datetime]: @@ -63,11 +125,19 @@ def _validate_risk_fields(finding: dict) -> None: from .scanners.port_scanner import PortScanner from .scanners.web_scanner import WebScanner from .scanners.recon_scanner import ReconScanner +from .scanners.network_vulnerability_scanner import NetworkVulnerabilityScanner +from .scanners.api_scanner import APIScanner +from .scanners.zap_scanner import ZAPScanner +from .scanners.xss_validation_scanner import XSSValidationScanner MODULAR_SCANNERS = { "port_scanner": PortScanner, "web_scanner": WebScanner, - "recon_scanner": ReconScanner + "recon_scanner": ReconScanner, + "network_scanner": NetworkVulnerabilityScanner, + "api_scanner": APIScanner, + "zap_scanner": ZAPScanner, + "xss_exploiter": XSSValidationScanner, } logger = logging.getLogger(__name__) @@ -83,11 +153,38 @@ def extract_target(inputs: Dict[str, Any]) -> str: or inputs.get("domain") or "" ) + + +def _stable_asset_id(target: str, host: Any, port: Any, protocol: Any) -> str: + material = "||".join( + [ + str(target or "").strip().lower(), + str(host or "").strip().lower(), + str(port or "").strip().lower(), + str(protocol or "").strip().lower(), + ] + ) + return f"asset:{uuid.uuid5(uuid.NAMESPACE_URL, material).hex[:16]}" + + +def _row_value(row: Any, key: str, default: Any = None) -> Any: + """Read a dict/sqlite row key with a default for backward-compatible mocks.""" + if row is None: + return default + if isinstance(row, dict): + return row.get(key, default) + try: + return row[key] + except (KeyError, IndexError, TypeError): + return default + + class TaskExecutor: """Executes security scanning tasks in isolated environments""" def __init__(self): self.running_tasks: Dict[str, asyncio.Task] = {} + self._process_pids: Dict[str, int] = {} # PubSub: Map of task_id to list of active async queues listening for output/status updates self._listeners: Dict[str, List[asyncio.Queue]] = {} self._capability_enforcer: CapabilityEnforcer = build_enforcer_from_settings() @@ -145,17 +242,23 @@ async def create_task( inputs: Dict[str, Any], safe_mode: bool, preset: Optional[str] = None, - consent_granted: bool = False + execution_context: Optional[Dict[str, Any]] = None, + consent_granted: bool = False, + owner_id: str = DEFAULT_OWNER_ID, ) -> str: """ Create a new scan task. - + Args: plugin_id: Plugin identifier inputs: User input values preset: Optional preset name consent_granted: Whether user granted consent - + owner_id: Owning user/workspace identity used to scope later + access (issue #401). Defaults to the shared default owner for + internal callers (workflows, scheduler, CLI) that are not tied + to a request. + Returns: Task ID """ @@ -177,17 +280,19 @@ async def create_task( await db.execute( """ INSERT INTO tasks ( - id, plugin_id, tool_name, target, inputs_json, preset, - status, scan_phase, consent_granted, safe_mode - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + id, owner_id, plugin_id, tool_name, target, inputs_json, preset, + execution_context_json, status, scan_phase, consent_granted, safe_mode + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( task_id, + owner_id, plugin_id, plugin.name, extract_target(inputs), json.dumps(inputs), preset, + serialize_execution_context(execution_context), TaskStatus.QUEUED.value, ScanPhase.QUEUED.value, consent_granted, @@ -199,7 +304,12 @@ async def create_task( await db.log_audit( "task_created", f"Task created for {plugin.name}", - context={"task_id": task_id, "plugin_id": plugin_id, "target": inputs.get("target")}, + context={ + "task_id": task_id, + "plugin_id": plugin_id, + "target": inputs.get("target"), + "execution_context": normalize_execution_context(execution_context), + }, task_id=task_id, plugin_id=plugin_id ) @@ -252,31 +362,131 @@ async def execute_task(self, task_id: str): self.running_tasks[task_id] = asyncio.current_task() try: - # Update status to running - await db.execute( - "UPDATE tasks SET status = ?, started_at = ? WHERE id = ?", - (TaskStatus.RUNNING.value, datetime.now().isoformat(), task_id) + # Update status to running — use optimistic lock to detect + # if the task was deleted or already running before this point. + result = await db.execute( + "UPDATE tasks SET status = ?, started_at = ? WHERE id = ? AND status = ?", + (TaskStatus.RUNNING.value, datetime.now().isoformat(), task_id, TaskStatus.QUEUED.value) ) + if result.rowcount == 0: + logger.warning(f"Task {task_id} was deleted or no longer queued before execution started. Aborting.") + self.running_tasks.pop(task_id, None) + return await self._invalidate_cached_views() # Get task details task_row = await db.fetchone( - "SELECT plugin_id, inputs_json, safe_mode FROM tasks WHERE id = ?", + "SELECT owner_id, plugin_id, inputs_json, execution_context_json, safe_mode FROM tasks WHERE id = ?", (task_id,) ) if not task_row: raise ValueError(f"Task not found: {task_id}") + owner_id = task_row["owner_id"] plugin_id = task_row["plugin_id"] inputs = json.loads(task_row["inputs_json"]) + execution_context = normalize_execution_context( + json.loads(task_row["execution_context_json"] or "{}") + ) safe_mode = bool(task_row["safe_mode"]) target = extract_target(inputs) + inputs = await self._hydrate_inputs_with_execution_context( + db=db, + owner_id=owner_id, + inputs=inputs, + execution_context=execution_context, + ) + + # ── Safe Mode & Network policy enforcement ─────────────────────── + # Enforce Safe Mode target validation inside TaskExecutor to guarantee + # that all execution paths (manual API, workflows, scheduled tasks) are protected. + if target: + plugin_manager = get_plugin_manager() + plugin = plugin_manager.get_plugin(plugin_id) + should_validate = True + if plugin and plugin.category == "code": + should_validate = False + + + # Use shared is_filesystem_target from validation to ensure + # consistent filesystem detection across route and executor layers. + from .validation import is_filesystem_target + is_fs = is_filesystem_target(target) + + if should_validate and not is_fs: + from .validation import validate_target + try: + # Enforce safe mode validation of target address in a thread pool + is_valid, error_msg = await asyncio.wait_for( + asyncio.to_thread(validate_target, target, safe_mode), + timeout=float(settings.dns_resolution_timeout_seconds), + ) + if not is_valid: + await self.mark_task_failed( + task_id, + f"Safe mode target validation failed: {error_msg}", + ) + await self._broadcast(task_id, "status", TaskStatus.FAILED.value) + return + except asyncio.TimeoutError: + await self.mark_task_failed( + task_id, + "Target validation timed out (SecuScan Guardrail)", + ) + await self._broadcast(task_id, "status", TaskStatus.FAILED.value) + return + + # Check before launching any scanner or subprocess. check_access() + # writes an audit entry on every path, so no extra logging needed. + if target and settings.enforce_network_policy: + engine = get_policy_engine() + try: + allowed, reason, _ = await asyncio.wait_for( + asyncio.to_thread( + engine.check_access, + dest_ip=target, + plugin_id=plugin_id, + task_id=task_id, + ), + timeout=float(settings.dns_resolution_timeout_seconds), + ) + except asyncio.TimeoutError: + allowed, reason = False, "Network policy check timed out (DNS resolution timeout)" + + if not allowed: + if settings.network_policy_failure_mode == "log_only": + logger.warning( + f"[Log Only] Network policy violation allowed for {target}: {reason}" + ) + else: + await self.mark_task_failed( + task_id, + f"Network policy denied access to {target}: {reason}", + ) + await self._broadcast(task_id, "status", TaskStatus.FAILED.value) + return # finally block handles running_tasks cleanup + limiter release # Check if this is a modular scanner or a standard plugin + plugin_manager = get_plugin_manager() + plugin = plugin_manager.get_plugin(plugin_id) + if not plugin: + raise ValueError(f"Plugin not found: {plugin_id}") + + self._capability_enforcer.check( + plugin_id=plugin.id, + declared=plugin.capabilities, + safety_level=plugin.safety.get("level", "safe"), + ) + + if plugin.safety.get("level") == "exploit" and not is_offensive_validation(execution_context): + raise ValueError( + "Exploit-level plugins require an execution context with validation_mode set to 'proof' or 'controlled_extract'." + ) + if plugin_id in MODULAR_SCANNERS: scanner_class = MODULAR_SCANNERS[plugin_id] - scanner = scanner_class(task_id, db) + scanner = scanner_class(task_id, db, safe_mode=safe_mode) logger.info(f"Executing modular scanner {plugin_id} for task {task_id}") await self._broadcast(task_id, "status", TaskStatus.RUNNING.value) @@ -315,6 +525,7 @@ async def execute_task(self, task_id: str): await self._upsert_findings_and_report_from_scanner( db=db, task_id=task_id, + owner_id=owner_id, scanner=scanner, plugin_id=plugin_id, target=target, @@ -325,18 +536,6 @@ async def execute_task(self, task_id: str): else: # Standard Plugin Execution - plugin_manager = get_plugin_manager() - plugin = plugin_manager.get_plugin(plugin_id) - if not plugin: - raise ValueError(f"Plugin not found: {plugin_id}") - - # Enforce capability policy before any command is built or process spawned - self._capability_enforcer.check( - plugin_id=plugin.id, - declared=plugin.capabilities, - safety_level=plugin.safety.get("level", "safe"), - ) - command = plugin_manager.build_command(plugin_id, inputs) if not command: @@ -344,6 +543,41 @@ async def execute_task(self, task_id: str): # Apply Docker Sandboxing if enabled if settings.docker_enabled: + # Validate the named Docker network exists before using it. + # If missing, attempt to create it automatically rather than failing. + _net_check = await asyncio.create_subprocess_exec( + "docker", "network", "inspect", settings.docker_network, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await _net_check.wait() + if _net_check.returncode != 0: + logger.info(f"Docker network '{settings.docker_network}' not found. Creating isolated bridge network (ICC disabled)...") + _net_create = await asyncio.create_subprocess_exec( + "docker", "network", "create", + "--driver", "bridge", + "--opt", "com.docker.network.bridge.enable_icc=false", + settings.docker_network, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await _net_create.wait() + if _net_create.returncode != 0: + logger.warning("Failed to create isolated bridge network with ICC disabled. Falling back to standard bridge...") + _net_create_fallback = await asyncio.create_subprocess_exec( + "docker", "network", "create", "--driver", "bridge", settings.docker_network, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + await _net_create_fallback.wait() + if _net_create_fallback.returncode != 0: + raise RuntimeError( + f"Docker network '{settings.docker_network}' does not exist and could not be created automatically." + ) + logger.info(f"Successfully created Docker network '{settings.docker_network}' (fallback)") + else: + logger.info(f"Successfully created Docker network '{settings.docker_network}' with ICC disabled") + docker_image = plugin.docker_image or "alpine:latest" docker_cmd = [ "docker", @@ -355,6 +589,8 @@ async def execute_task(self, task_id: str): f"{settings.sandbox_memory_mb}m", "--cpus", str(settings.sandbox_cpu_quota), + "--cap-drop", "NET_RAW", + "--network", settings.docker_network, docker_image, ] command = docker_cmd + command @@ -415,6 +651,7 @@ async def execute_task(self, task_id: str): await self._upsert_findings_and_report( db=db, task_id=task_id, + owner_id=owner_id, plugin=plugin, plugin_id=plugin_id, target=target, @@ -423,6 +660,8 @@ async def execute_task(self, task_id: str): ) await self._broadcast_phase(task_id, ScanPhase.REPORTING.value) + await self._dispatch_task_notifications(db, task_id) + await self._broadcast_phase(task_id, ScanPhase.FINISHED.value) await self._broadcast(task_id, "status", final_status) await self._invalidate_cached_views() @@ -536,6 +775,7 @@ async def execute_task(self, task_id: str): # Always clean up: remove from the in-memory registry and # release the concurrency slot regardless of how the task ended. self.running_tasks.pop(task_id, None) + self._process_pids.pop(task_id, None) await concurrent_limiter.release(task_id) async def _execute_command( @@ -559,8 +799,10 @@ async def _execute_command( process = await asyncio.create_subprocess_exec( *command, stdout=subprocess.PIPE, - stderr=subprocess.STDOUT + stderr=subprocess.STDOUT, + start_new_session=True, ) + self._process_pids[task_id] = process.pid output_lines = [] @@ -568,35 +810,50 @@ async def read_stream(): stdout = process.stdout if stdout is None: return - while not stdout.at_eof(): line = await stdout.readline() if line: - decoded_line = line.decode('utf-8', errors='replace') + decoded_line = line.decode("utf-8", errors="replace") output_lines.append(decoded_line) await self._broadcast(task_id, "output", decoded_line) try: await asyncio.wait_for(read_stream(), timeout=timeout) await process.wait() + self._process_pids.pop(task_id, None) return "".join(output_lines), process.returncode if process.returncode is not None else -1 except asyncio.TimeoutError: - process.kill() - await process.wait() + logger.warning( + "Task %s timed out after %ds — terminating process group (pid=%d)", + task_id, timeout, process.pid, + ) + await _terminate_process_group(process.pid, task_id) + try: + await asyncio.wait_for(process.wait(), timeout=3) + except asyncio.TimeoutError: + pass + self._process_pids.pop(task_id, None) return "".join(output_lines) + "\nTask timed out", -1 except asyncio.CancelledError: - # Handle task cancellation by killing the subprocess - logger.warning(f"Task {task_id} cancelled. Killing process {process.pid}") + logger.warning( + "Task %s cancelled — terminating process group (pid=%d)", + task_id, process.pid, + ) + await _terminate_process_group(process.pid, task_id) try: - process.kill() - await process.wait() - except Exception as e: - logger.error(f"Error killing process for cancelled task {task_id}: {e}") + await asyncio.wait_for(process.wait(), timeout=3) + except asyncio.TimeoutError: + pass + self._process_pids.pop(task_id, None) raise + except asyncio.CancelledError: + self._process_pids.pop(task_id, None) + raise except Exception as e: + self._process_pids.pop(task_id, None) logger.error(f"Failed to execute command: {e}") return f"Execution error: {str(e)}", -1 @@ -670,6 +927,11 @@ async def cancel_task(self, task_id: str) -> bool: if task_id not in self.running_tasks: return False task = self.running_tasks[task_id] + + pid = self._process_pids.get(task_id) + if pid is not None: + await _terminate_process_group(pid, task_id) + task.cancel() # If docker is enabled, forcefully kill the sandbox container @@ -707,7 +969,7 @@ async def get_task_status(self, task_id: str) -> Optional[Dict]: task_row = await db.fetchone( """ SELECT id, plugin_id, tool_name, target, status, scan_phase, created_at, started_at, completed_at, - duration_seconds, exit_code, error_message, preset, inputs_json + duration_seconds, exit_code, error_message, preset, inputs_json, execution_context_json FROM tasks WHERE id = ? """, (task_id,) @@ -741,86 +1003,381 @@ async def get_task_status(self, task_id: str) -> Optional[Dict]: "exit_code": task_row["exit_code"], "error_message": task_row["error_message"], "preset": task_row["preset"], + "execution_context": normalize_execution_context( + json.loads(_row_value(task_row, "execution_context_json", "{}") or "{}") + ), "queue_position": queue_position, "pending_count": pending_count, } - async def _upsert_findings_and_report(self, db, task_id: str, plugin, plugin_id: str, target: str, status: str, output: str = ""): - """Persist derived findings and report records into SQLite.""" - parsed = self._parse_results(plugin, output) - findings_data = parsed.get("findings", []) - - # Update task with structured results + async def _hydrate_inputs_with_execution_context( + self, + *, + db, + owner_id: str, + inputs: Dict[str, Any], + execution_context: Dict[str, Any], + ) -> Dict[str, Any]: + """Add auth/session material derived from stored profiles.""" + effective_inputs = dict(inputs) + target_policy = await get_target_policy( + db, + owner_id, + execution_context.get("target_policy_id"), + ) + if target_policy: + effective_inputs["__target_policy"] = target_policy + + credential_profile = await get_credential_profile( + db, + owner_id, + execution_context.get("credential_profile_id"), + ) + if credential_profile: + headers = credential_profile.get("extra_headers", {}) or {} + effective_inputs["__extra_headers"] = { + str(key): str(value) for key, value in headers.items() + } + username = await self._read_vault_secret(db, credential_profile.get("username_secret_name")) + password = await self._read_vault_secret(db, credential_profile.get("password_secret_name")) + if username is not None and password is not None: + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") + effective_inputs.setdefault("__extra_headers", {}) + effective_inputs["__extra_headers"]["Authorization"] = f"Basic {token}" + + session_profile = await get_session_profile( + db, + owner_id, + execution_context.get("session_profile_id"), + ) + if session_profile: + extra_headers = session_profile.get("extra_headers", {}) or {} + if extra_headers: + effective_inputs.setdefault("__extra_headers", {}) + for key, value in extra_headers.items(): + effective_inputs["__extra_headers"][str(key)] = str(value) + cookie_secret = await self._read_vault_secret(db, session_profile.get("cookie_secret_name")) + if cookie_secret: + try: + parsed = json.loads(cookie_secret) + if isinstance(parsed, dict): + effective_inputs["__cookies"] = { + str(key): str(value) for key, value in parsed.items() + } + except json.JSONDecodeError: + effective_inputs["__cookies"] = {"session": cookie_secret} + + effective_inputs["__execution_context"] = execution_context + return effective_inputs + + async def _read_vault_secret(self, db, secret_name: Any) -> Optional[str]: + if not secret_name: + return None + row = await db.fetchone( + "SELECT encrypted_value FROM credential_vault WHERE name = ?", + (str(secret_name),), + ) + if not row: + return None + crypto = VaultCrypto(settings.resolved_vault_key) + return crypto.decrypt(row["encrypted_value"]) + + def _deserialize_finding_rows(self, rows: List[Any]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + json_fields = { + "metadata_json": "metadata", + "risk_factors_json": "risk_factors", + "evidence_json": "evidence", + "asset_refs_json": "asset_refs", + "references_json": "references", + "corroborating_sources_json": "corroborating_sources", + } + for row in rows: + item = dict(row) + for source_key, target_key in json_fields.items(): + value = item.pop(source_key, None) + if isinstance(value, str): + try: + item[target_key] = json.loads(value) + except json.JSONDecodeError: + item[target_key] = value + elif value is not None: + item[target_key] = value + findings.append(item) + return findings + + async def _load_previous_task_findings( + self, + db, + *, + owner_id: str, + plugin_id: str, + target: str, + task_id: str, + ) -> List[Dict[str, Any]]: + previous_task = await db.fetchone( + """ + SELECT id + FROM tasks + WHERE owner_id = ? AND plugin_id = ? AND target = ? AND id != ? + AND status IN (?, ?) + ORDER BY COALESCE(completed_at, created_at) DESC + LIMIT 1 + """, + ( + owner_id, + plugin_id, + target, + task_id, + TaskStatus.COMPLETED.value, + TaskStatus.FAILED.value, + ), + ) + if not previous_task: + return [] + + rows = await db.fetchall( + "SELECT * FROM findings WHERE owner_id = ? AND task_id = ? ORDER BY discovered_at DESC", + (owner_id, previous_task["id"]), + ) + return self._deserialize_finding_rows(rows) + + def _normalize_asset_service_record(self, target: str, service: Dict[str, Any]) -> Dict[str, Any]: + metadata = service.get("metadata", {}) if isinstance(service.get("metadata"), dict) else {} + host = str(service.get("host") or target) + port = service.get("port") + protocol = service.get("protocol") + cert_san = service.get("cert_san") or service.get("cert_sans") or metadata.get("cert_san") or metadata.get("cert_sans") or [] + if not isinstance(cert_san, list): + cert_san = [cert_san] + fingerprint = service.get("service_fingerprint") + if not fingerprint: + fingerprint = " ".join( + str(part).strip() + for part in ( + service.get("product"), + service.get("version"), + service.get("service"), + service.get("title"), + ) + if str(part or "").strip() + ) or None + return { + **service, + "host": host, + "target": target, + "asset_id": str(service.get("asset_id") or _stable_asset_id(target, host, port, protocol)), + "cert_san": cert_san, + "metadata": metadata, + "service_fingerprint": fingerprint, + } + + def _build_severity_counts(self, findings: List[Dict[str, Any]]) -> Dict[str, int]: + severity_counts: Dict[str, int] = {} + for finding in findings: + severity = str(finding.get("severity", "info")).lower() + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + return severity_counts + + async def _build_result_contract( + self, + db, + *, + task_id: str, + owner_id: str, + plugin_id: str, + target: str, + result: Dict[str, Any], + ) -> tuple[Dict[str, Any], List[Dict[str, Any]], List[Dict[str, Any]]]: + normalized_findings = await normalize_and_correlate_findings( + db, + owner_id=owner_id, + plugin_id=plugin_id, + target=target, + findings=[item for item in result.get("findings", []) if isinstance(item, dict)], + ) + previous_findings = await self._load_previous_task_findings( + db, + owner_id=owner_id, + plugin_id=plugin_id, + target=target, + task_id=task_id, + ) + asset_services = [ + self._normalize_asset_service_record(target, item) + for item in (result.get("asset_services") or result.get("services") or []) + if isinstance(item, dict) + ] + structured_result = dict(result) + structured_result["findings"] = normalized_findings + structured_result["asset_services"] = asset_services + structured_result["services"] = asset_services + structured_result["finding_groups"] = build_finding_groups(normalized_findings) + structured_result["asset_summary"] = build_asset_summary(normalized_findings, asset_services) + structured_result["scan_diff"] = build_scan_diff(normalized_findings, previous_findings) + structured_result["severity_counts"] = self._build_severity_counts(normalized_findings) + structured_result["count"] = len(normalized_findings) + return structured_result, previous_findings, asset_services + + async def _persist_finding( + self, + db, + *, + owner_id: str, + task_id: str, + plugin_id: str, + target: str, + finding: Dict[str, Any], + ) -> Dict[str, Any]: + u_id = str(uuid.uuid4()).replace("-", "") + finding_id = f"finding:{task_id}:{u_id[:8]}" + + _validate_risk_fields(finding) + exploitability = finding.get("exploitability") + confidence = finding.get("confidence") + asset_exposure = finding.get("asset_exposure") + discovered = _parse_discovered_at(finding) + target_value = str(finding.get("target") or target) + metadata = finding.get("metadata", {}) if isinstance(finding.get("metadata"), dict) else {} + evidence = finding.get("evidence", []) if isinstance(finding.get("evidence"), list) else [] + asset_refs = finding.get("asset_refs", []) if isinstance(finding.get("asset_refs"), list) else [] + references = finding.get("references", []) if isinstance(finding.get("references"), list) else [] + corroborating_sources = finding.get("corroborating_sources", []) if isinstance(finding.get("corroborating_sources"), list) else [] + first_seen_at = str(finding.get("first_seen_at") or discovered.isoformat()) + last_seen_at = str(finding.get("last_seen_at") or discovered.isoformat()) + occurrence_count = int(finding.get("occurrence_count") or 1) + evidence_count = int(finding.get("evidence_count") or len(evidence)) + risk_score = compute_risk_score( + severity=finding["severity"], + exploitability=exploitability, + asset_exposure=asset_exposure, + discovered_at=discovered, + confidence=confidence, + ) + risk_factors = compute_risk_factors( + severity=finding["severity"], + exploitability=exploitability, + asset_exposure=asset_exposure, + discovered_at=discovered, + confidence=confidence, + risk_score=risk_score, + ) + await db.execute( - "UPDATE tasks SET structured_json = ? WHERE id = ?", - (json.dumps(parsed), task_id) + """ + INSERT INTO findings ( + id, owner_id, task_id, plugin_id, title, category, severity, + target, description, remediation, proof, cvss, cve, + metadata_json, discovered_at, + exploitability, confidence, validated, validation_method, + confidence_reason, finding_kind, finding_group_id, asset_id, + first_seen_at, last_seen_at, occurrence_count, corroborating_sources_json, + evidence_count, analyst_status, retest_status, evidence_json, asset_refs_json, + service_fingerprint, cpe, references_json, + asset_exposure, risk_score, risk_factors_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + finding_id, + owner_id, + task_id, + plugin_id, + finding["title"], + finding["category"], + finding["severity"], + target_value, + finding["description"], + finding.get("remediation", ""), + finding.get("proof"), + finding.get("cvss"), + finding.get("cve"), + json.dumps(metadata), + discovered.isoformat(), + exploitability, + confidence, + 1 if finding.get("validated") else 0, + finding.get("validation_method"), + finding.get("confidence_reason"), + str(finding.get("finding_kind") or "observation"), + finding.get("finding_group_id"), + finding.get("asset_id"), + first_seen_at, + last_seen_at, + occurrence_count, + json.dumps(corroborating_sources), + evidence_count, + str(finding.get("analyst_status") or "new"), + str(finding.get("retest_status") or "not_requested"), + json.dumps(evidence), + json.dumps(asset_refs), + finding.get("service_fingerprint"), + finding.get("cpe"), + json.dumps(references), + asset_exposure, + risk_score, + json.dumps(risk_factors), + ), ) + return { + **finding, + "id": finding_id, + "plugin_id": plugin_id, + "target": target_value, + "discovered_at": discovered.isoformat(), + "metadata": metadata, + "evidence": evidence, + "asset_refs": asset_refs, + "references": references, + "corroborating_sources": corroborating_sources, + "first_seen_at": first_seen_at, + "last_seen_at": last_seen_at, + "occurrence_count": occurrence_count, + "evidence_count": evidence_count, + "risk_score": risk_score, + "risk_factors": risk_factors, + } - # Insert findings - for finding in findings_data: - u_id = str(uuid.uuid4()).replace("-", "") - finding_id = f"finding:{task_id}:{u_id[:8]}" - - _validate_risk_fields(finding) - exploitability = finding.get("exploitability") - confidence = finding.get("confidence") - asset_exposure = finding.get("asset_exposure") - discovered = _parse_discovered_at(finding) - risk_score = compute_risk_score( - severity=finding["severity"], - exploitability=exploitability, - asset_exposure=asset_exposure, - discovered_at=discovered, - confidence=confidence, - ) - risk_factors = compute_risk_factors( - severity=finding["severity"], - exploitability=exploitability, - asset_exposure=asset_exposure, - discovered_at=discovered, - confidence=confidence, - risk_score=risk_score, + async def _upsert_findings_and_report(self, db, task_id: str, owner_id: str, plugin, plugin_id: str, target: str, status: str, output: str = ""): + """Persist derived findings and report records into SQLite.""" + parsed = self._parse_results(plugin, output) + structured_result, previous_findings, asset_services = await self._build_result_contract( + db, + task_id=task_id, + owner_id=owner_id, + plugin_id=plugin_id, + target=target, + result=parsed, + ) + findings_data: List[Dict[str, Any]] = [] + for finding in structured_result.get("findings", []): + findings_data.append( + await self._persist_finding( + db, + owner_id=owner_id, + task_id=task_id, + plugin_id=plugin_id, + target=target, + finding=finding, + ) ) - await db.execute( - """ - INSERT INTO findings ( - id, task_id, plugin_id, title, category, severity, - target, description, remediation, proof, cvss, cve, - metadata_json, discovered_at, - exploitability, confidence, asset_exposure, - risk_score, risk_factors_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?) - """, - ( - finding_id, - task_id, - plugin_id, - finding["title"], - finding["category"], - finding["severity"], - target, - finding["description"], - finding.get("remediation", ""), - finding.get("proof"), - finding.get("cvss"), - finding.get("cve"), - json.dumps(finding.get("metadata", {})), - discovered.isoformat() if discovered else datetime.now(timezone.utc).isoformat(), - exploitability, - confidence, - asset_exposure, - risk_score, - json.dumps(risk_factors), - ), - ) + structured_result["findings"] = findings_data + structured_result["severity_counts"] = self._build_severity_counts(findings_data) + structured_result["finding_groups"] = build_finding_groups(findings_data) + structured_result["asset_summary"] = build_asset_summary(findings_data, asset_services) + structured_result["scan_diff"] = build_scan_diff(findings_data, previous_findings) + + await db.execute( + "UPDATE tasks SET structured_json = ? WHERE id = ?", + (json.dumps(structured_result), task_id) + ) await db.execute( """ INSERT INTO reports ( - id, task_id, name, type, generated_at, status, findings, pages - ) VALUES (?, ?, ?, ?, (datetime('now')), ?, ?, ?) + id, owner_id, task_id, name, type, generated_at, status, findings, pages + ) VALUES (?, ?, ?, ?, ?, (datetime('now')), ?, ?, ?) ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, findings = EXCLUDED.findings, @@ -828,6 +1385,7 @@ async def _upsert_findings_and_report(self, db, task_id: str, plugin, plugin_id: """, ( f"report:{task_id}", + owner_id, task_id, f"{plugin.name} Report", "technical", @@ -837,76 +1395,55 @@ async def _upsert_findings_and_report(self, db, task_id: str, plugin, plugin_id: ), ) - async def _upsert_findings_and_report_from_scanner(self, db, task_id: str, scanner: Any, plugin_id: str, target: str, status: str, result: Dict[str, Any]): - """Persist modular scanner results into findings, and reports.""" - findings_data = result.get("findings", []) - - # Insert findings - for finding in findings_data: - u_id = str(uuid.uuid4()).replace("-", "") - finding_id = f"finding:{task_id}:{u_id[:8]}" - - _validate_risk_fields(finding) - exploitability = finding.get("exploitability") - confidence = finding.get("confidence") - asset_exposure = finding.get("asset_exposure") - discovered = _parse_discovered_at(finding) - risk_score = compute_risk_score( - severity=finding["severity"], - exploitability=exploitability, - asset_exposure=asset_exposure, - discovered_at=discovered, - confidence=confidence, - ) - risk_factors = compute_risk_factors( - severity=finding["severity"], - exploitability=exploitability, - asset_exposure=asset_exposure, - discovered_at=discovered, - confidence=confidence, - risk_score=risk_score, - ) + await self._persist_result_resources( + db, + owner_id=owner_id, + task_id=task_id, + plugin_id=plugin_id, + target=target, + result=structured_result, + ) - await db.execute( - """ - INSERT INTO findings ( - id, task_id, plugin_id, title, category, severity, - target, description, remediation, proof, cvss, cve, - metadata_json, discovered_at, - exploitability, confidence, asset_exposure, - risk_score, risk_factors_json - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, - ?, ?, ?, ?, ?) - """, - ( - finding_id, - task_id, - plugin_id, - finding["title"], - finding["category"], - finding["severity"], - target, - finding["description"], - finding.get("remediation", ""), - finding.get("proof"), - finding.get("cvss"), - finding.get("cve"), - json.dumps(finding.get("metadata", {})), - discovered.isoformat() if discovered else datetime.now(timezone.utc).isoformat(), - exploitability, - confidence, - asset_exposure, - risk_score, - json.dumps(risk_factors), + async def _upsert_findings_and_report_from_scanner(self, db, task_id: str, owner_id: str, scanner: Any, plugin_id: str, target: str, status: str, result: Dict[str, Any]): + """Persist modular scanner results into findings, and reports.""" + structured_result, previous_findings, asset_services = await self._build_result_contract( + db, + task_id=task_id, + owner_id=owner_id, + plugin_id=plugin_id, + target=target, + result=result, + ) + findings_data: List[Dict[str, Any]] = [] + for finding in structured_result.get("findings", []): + findings_data.append( + await self._persist_finding( + db, + owner_id=owner_id, + task_id=task_id, + plugin_id=plugin_id, + target=target, + finding=finding, ) ) + structured_result["findings"] = findings_data + structured_result["severity_counts"] = self._build_severity_counts(findings_data) + structured_result["finding_groups"] = build_finding_groups(findings_data) + structured_result["asset_summary"] = build_asset_summary(findings_data, asset_services) + structured_result["scan_diff"] = build_scan_diff(findings_data, previous_findings) + + await db.execute( + "UPDATE tasks SET structured_json = ? WHERE id = ?", + (json.dumps(structured_result), task_id) + ) + # Create/Update report await db.execute( """ INSERT INTO reports ( - id, task_id, name, type, generated_at, status, findings, pages - ) VALUES (?, ?, ?, ?, (datetime('now')), ?, ?, ?) + id, owner_id, task_id, name, type, generated_at, status, findings, pages + ) VALUES (?, ?, ?, ?, ?, (datetime('now')), ?, ?, ?) ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, findings = EXCLUDED.findings, @@ -914,6 +1451,7 @@ async def _upsert_findings_and_report_from_scanner(self, db, task_id: str, scann """, ( f"report:{task_id}", + owner_id, task_id, f"{scanner.name} Report", "professional" if status == TaskStatus.COMPLETED.value else "failed", @@ -923,6 +1461,47 @@ async def _upsert_findings_and_report_from_scanner(self, db, task_id: str, scann ), ) + await self._persist_result_resources( + db, + owner_id=owner_id, + task_id=task_id, + plugin_id=plugin_id, + target=target, + result=structured_result, + ) + + async def _persist_result_resources( + self, + db, + *, + owner_id: str, + task_id: str, + plugin_id: str, + target: str, + result: Dict[str, Any], + ) -> None: + crawl = result.get("crawl") + if isinstance(crawl, dict) and crawl: + await persist_crawl_run( + db, + owner_id=owner_id, + task_id=task_id, + plugin_id=plugin_id, + target=target, + crawl=crawl, + ) + + asset_services = result.get("asset_services") or result.get("services") + if isinstance(asset_services, list) and asset_services: + await replace_asset_services( + db, + owner_id=owner_id, + task_id=task_id, + plugin_id=plugin_id, + target=target, + services=[item for item in asset_services if isinstance(item, dict)], + ) + def _parse_results(self, plugin, output: str) -> Dict[str, Any]: """Route to appropriate parser based on plugin metadata.""" parser_type = plugin.output.get("parser") @@ -1056,7 +1635,21 @@ def _normalize_finding(self, plugin, finding: Dict[str, Any]) -> Dict[str, Any]: "severity": normalized_severity, "description": str(description), "remediation": str(finding.get("remediation", "")), + "proof": finding.get("proof"), + "cvss": finding.get("cvss"), + "cve": finding.get("cve"), "metadata": metadata, + "exploitability": finding.get("exploitability"), + "confidence": finding.get("confidence"), + "validated": bool(finding.get("validated", False)), + "validation_method": finding.get("validation_method"), + "confidence_reason": finding.get("confidence_reason"), + "evidence": finding.get("evidence", []) if isinstance(finding.get("evidence"), list) else [], + "asset_refs": finding.get("asset_refs", []) if isinstance(finding.get("asset_refs"), list) else [], + "service_fingerprint": finding.get("service_fingerprint"), + "cpe": finding.get("cpe"), + "references": finding.get("references", []) if isinstance(finding.get("references"), list) else [], + "asset_exposure": finding.get("asset_exposure"), } def _parse_json_fallback_findings(self, plugin, parser_input: str) -> List[Dict[str, Any]]: @@ -1184,6 +1777,25 @@ def _parse_http_output(self, output: str) -> Dict[str, Any]: "findings": findings } + async def _dispatch_task_notifications(self, db, task_id: str) -> None: + """Evaluate notification rules for all findings on a completed task.""" + try: + results = await process_task_notifications(db, task_id) + sent = sum( + 1 + for r in results + if not r.skipped and r.status == NotificationDeliveryStatus.SUCCESS + ) + if sent: + logger.info("Task %s: delivered %d notification(s)", task_id, sent) + except Exception as exc: + logger.warning( + "Task %s: notification dispatch failed: %s", + task_id, + exc, + exc_info=True, + ) + async def _invalidate_cached_views(self): """Clear cached aggregate views after write operations.""" try: diff --git a/backend/secuscan/finding_intelligence.py b/backend/secuscan/finding_intelligence.py new file mode 100644 index 000000000..322619c15 --- /dev/null +++ b/backend/secuscan/finding_intelligence.py @@ -0,0 +1,565 @@ +"""Finding normalization, correlation, grouping, and diff helpers.""" + +from __future__ import annotations + +import hashlib +import json +import re +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, List, Optional +from urllib.parse import urlparse + + +_OBSERVATION_CATEGORIES = { + "attack surface", + "asset discovery", + "api discovery", + "api exposure", + "service exposure", + "information disclosure", + "technology fingerprint", + "transport security", + "certificate hygiene", +} + +_SOURCE_QUALITY = { + "nuclei": 0.8, + "nikto": 0.7, + "ffuf": 0.7, + "nmap": 0.78, + "http_probe": 0.82, + "http_inspector": 0.7, + "crawl": 0.68, + "graphql": 0.82, + "openapi": 0.8, + "knowledgebase": 0.72, + "tls_probe": 0.8, + "socket_probe": 0.76, +} + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_timestamp(raw: Any) -> str: + if isinstance(raw, datetime): + return raw.astimezone(timezone.utc).isoformat() + if isinstance(raw, str) and raw.strip(): + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone(timezone.utc).isoformat() + except ValueError: + return _now_iso() + return _now_iso() + + +def _stable_id(prefix: str, *parts: Any) -> str: + material = "||".join(str(part or "").strip().lower() for part in parts) + digest = hashlib.sha1(material.encode("utf-8")).hexdigest()[:16] + return f"{prefix}:{digest}" + + +def _normalize_severity(value: Any) -> str: + severity = str(value or "info").lower() + mapping = { + "critical": "critical", + "high": "high", + "medium": "medium", + "moderate": "medium", + "low": "low", + "info": "info", + "informational": "info", + "note": "info", + } + return mapping.get(severity, "info") + + +def _severity_rank(value: str) -> int: + order = {"critical": 5, "high": 4, "medium": 3, "low": 2, "info": 1} + return order.get(_normalize_severity(value), 1) + + +def _normalize_url_path(value: str) -> str: + parsed = urlparse(value) + if parsed.scheme or parsed.netloc: + return parsed.path.rstrip("/") or "/" + if value.startswith("/"): + return value.rstrip("/") or "/" + return "" + + +def _extract_best_url(finding: Dict[str, Any]) -> str: + metadata = finding.get("metadata") if isinstance(finding.get("metadata"), dict) else {} + for key in ("url", "matched_at", "endpoint", "action"): + value = metadata.get(key) + if isinstance(value, str) and value.startswith(("http://", "https://")): + return value + for evidence in finding.get("evidence", []) if isinstance(finding.get("evidence"), list) else []: + if not isinstance(evidence, dict): + continue + value = evidence.get("value") + if isinstance(value, str) and value.startswith(("http://", "https://")): + return value + target = str(finding.get("target") or "") + return target if target.startswith(("http://", "https://")) else "" + + +def _guess_asset_ref(finding: Dict[str, Any], target: str) -> str: + asset_refs = finding.get("asset_refs") if isinstance(finding.get("asset_refs"), list) else [] + if asset_refs: + first = asset_refs[0] + if isinstance(first, str) and first.strip(): + return first.strip() + best_url = _extract_best_url(finding) + if best_url: + parsed = urlparse(best_url) + return f"{parsed.scheme}://{parsed.netloc}{parsed.path or '/'}" + metadata = finding.get("metadata") if isinstance(finding.get("metadata"), dict) else {} + host = metadata.get("host") or target + port = metadata.get("port") + protocol = metadata.get("protocol") + if port: + return f"{host}:{port}/{protocol or 'tcp'}" + return str(host or target) + + +def _issue_signature(finding: Dict[str, Any]) -> str: + cve = str(finding.get("cve") or "").strip().lower() + if cve: + return f"cve:{cve}" + + metadata = finding.get("metadata") if isinstance(finding.get("metadata"), dict) else {} + path = _normalize_url_path(_extract_best_url(finding)) + detail = ( + metadata.get("template") + or metadata.get("header") + or metadata.get("cookie_name") + or metadata.get("policy") + or metadata.get("service") + or metadata.get("endpoint") + or metadata.get("port") + or metadata.get("cms") + or "" + ) + base = "|".join( + [ + str(finding.get("category") or "").strip().lower(), + str(finding.get("title") or "").strip().lower(), + str(finding.get("validation_method") or "").strip().lower(), + str(detail).strip().lower(), + path, + ] + ) + compact = re.sub(r"[^a-z0-9|:/._-]+", "-", base) + return compact.strip("-") or "finding" + + +def _typed_evidence( + item: Any, + *, + source: str, + observed_at: str, + confidence: float, +) -> Dict[str, Any]: + if isinstance(item, dict): + evidence_type = str(item.get("type") or "evidence") + label = str(item.get("label") or evidence_type.replace("_", " ").title()) + value = item.get("value") + artifact_ref = item.get("artifact_ref") + item_source = str(item.get("source") or source) + item_confidence = item.get("confidence") + normalized_confidence = float(item_confidence) if isinstance(item_confidence, (int, float)) else confidence + return { + "type": evidence_type, + "label": label, + "value": value, + "artifact_ref": artifact_ref, + "source": item_source, + "observed_at": str(item.get("observed_at") or observed_at), + "confidence": max(0.0, min(1.0, normalized_confidence)), + } + return { + "type": "evidence", + "label": "Evidence", + "value": item, + "artifact_ref": None, + "source": source, + "observed_at": observed_at, + "confidence": max(0.0, min(1.0, confidence)), + } + + +def _dedupe_evidence(items: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]: + unique: List[Dict[str, Any]] = [] + seen = set() + for item in items: + key = json.dumps( + { + "type": item.get("type"), + "label": item.get("label"), + "value": item.get("value"), + "artifact_ref": item.get("artifact_ref"), + "source": item.get("source"), + }, + sort_keys=True, + default=str, + ) + if key in seen: + continue + seen.add(key) + unique.append(item) + return unique + + +def _merge_text(primary: str, fallback: str) -> str: + return primary if str(primary or "").strip() else fallback + + +def _build_confidence_reason( + *, + finding_kind: str, + evidence_count: int, + corroborating_sources: List[str], + occurrence_count: int, + match_strength: str, +) -> str: + parts = [ + f"{finding_kind.replace('_', ' ')} classification", + f"{evidence_count} evidence item{'s' if evidence_count != 1 else ''}", + ] + if corroborating_sources: + parts.append(f"corroborated by {len(corroborating_sources)} source{'s' if len(corroborating_sources) != 1 else ''}") + if occurrence_count > 1: + parts.append(f"seen across {occurrence_count} scan observations") + if match_strength and match_strength != "none": + parts.append(f"{match_strength} fingerprint match") + return "; ".join(parts).capitalize() + "." + + +def _finding_kind_for(finding: Dict[str, Any]) -> str: + category = str(finding.get("category") or "").strip().lower() + severity = _normalize_severity(finding.get("severity")) + if finding.get("validated") and category not in _OBSERVATION_CATEGORIES and severity in {"critical", "high", "medium", "low"}: + return "validated_issue" + if category in _OBSERVATION_CATEGORIES and not finding.get("cve"): + return "observation" + if severity in {"critical", "high", "medium"} or finding.get("cve") or finding.get("validation_method") == "cpe_cve_correlation": + return "suspected_issue" + return "observation" + + +def _fingerprint_score(finding: Dict[str, Any]) -> tuple[float, str]: + metadata = finding.get("metadata") if isinstance(finding.get("metadata"), dict) else {} + match_strength = str( + metadata.get("match_strength") + or metadata.get("cpe_match_strength") + or ("validated" if finding.get("validated") else "none") + ).lower() + mapping = {"validated": 1.0, "exact": 0.95, "strong_fuzzy": 0.8, "fuzzy": 0.7, "family": 0.45, "none": 0.25} + return mapping.get(match_strength, 0.35), match_strength + + +def _source_quality(sources: Iterable[str]) -> float: + values = [_SOURCE_QUALITY.get(str(source).lower(), 0.58) for source in sources if str(source).strip()] + return max(values) if values else 0.58 + + +def _compute_confidence( + finding: Dict[str, Any], + *, + corroborating_sources: List[str], + occurrence_count: int, + evidence: List[Dict[str, Any]], +) -> float: + fingerprint_score, _ = _fingerprint_score(finding) + base = 0.18 + source_component = _source_quality(corroborating_sources) * 0.28 + evidence_component = min(0.2, 0.05 * len(evidence)) + repeatability_component = min(0.15, 0.05 * max(0, occurrence_count - 1)) + corroboration_component = min(0.12, 0.06 * max(0, len(corroborating_sources) - 1)) + fingerprint_component = fingerprint_score * 0.18 + validation_component = 0.12 if finding.get("validated") else 0.04 if finding.get("cve") else 0.0 + severity_component = {"critical": 0.08, "high": 0.06, "medium": 0.04, "low": 0.02, "info": 0.0}.get( + _normalize_severity(finding.get("severity")), + 0.0, + ) + score = ( + base + + source_component + + evidence_component + + repeatability_component + + corroboration_component + + fingerprint_component + + validation_component + + severity_component + ) + return round(max(0.0, min(0.99, score)), 2) + + +def _sort_sources(sources: Iterable[str]) -> List[str]: + return sorted({str(source).strip() for source in sources if str(source).strip()}) + + +async def normalize_and_correlate_findings( + db: Any, + *, + owner_id: str, + plugin_id: str, + target: str, + findings: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Normalize evidence and correlate repeated findings across scans.""" + observed_at = _now_iso() + staged: Dict[str, Dict[str, Any]] = {} + + for raw_finding in findings: + finding = dict(raw_finding or {}) + severity = _normalize_severity(finding.get("severity")) + asset_ref = _guess_asset_ref(finding, target) + asset_id = _stable_id("asset", target, asset_ref) + finding_group_id = _stable_id("group", plugin_id, asset_id, _issue_signature(finding)) + base_source = str( + (finding.get("metadata") or {}).get("source") + if isinstance(finding.get("metadata"), dict) + else "" + ) or plugin_id + + normalized_evidence = _dedupe_evidence( + [ + _typed_evidence(item, source=base_source, observed_at=observed_at, confidence=0.72) + for item in (finding.get("evidence") if isinstance(finding.get("evidence"), list) else []) + ] + ) + sources = _sort_sources( + [base_source] + + [item.get("source", "") for item in normalized_evidence if isinstance(item, dict)] + ) + + staged_item = staged.get(finding_group_id) + if staged_item is None: + staged[finding_group_id] = { + **finding, + "severity": severity, + "target": str(finding.get("target") or target), + "asset_refs": sorted({asset_ref, *[str(ref) for ref in finding.get("asset_refs", []) if str(ref).strip()]}), + "asset_id": asset_id, + "finding_group_id": finding_group_id, + "evidence": normalized_evidence, + "corroborating_sources": sources, + "metadata": dict(finding.get("metadata") or {}), + "occurrence_count": 1, + "discovered_at": str(finding.get("discovered_at") or observed_at), + } + continue + + staged_item["occurrence_count"] = int(staged_item.get("occurrence_count", 1)) + 1 + if _severity_rank(severity) > _severity_rank(staged_item.get("severity", "info")): + staged_item["severity"] = severity + staged_item["validated"] = bool(staged_item.get("validated")) or bool(finding.get("validated")) + staged_item["cvss"] = staged_item.get("cvss") or finding.get("cvss") + staged_item["cve"] = staged_item.get("cve") or finding.get("cve") + staged_item["cpe"] = staged_item.get("cpe") or finding.get("cpe") + staged_item["service_fingerprint"] = staged_item.get("service_fingerprint") or finding.get("service_fingerprint") + staged_item["description"] = _merge_text(staged_item.get("description", ""), finding.get("description", "")) + staged_item["remediation"] = _merge_text(staged_item.get("remediation", ""), finding.get("remediation", "")) + staged_item["proof"] = _merge_text(staged_item.get("proof", ""), finding.get("proof", "")) + staged_item["validation_method"] = _merge_text(staged_item.get("validation_method", ""), finding.get("validation_method", "")) + staged_item["confidence_reason"] = _merge_text(staged_item.get("confidence_reason", ""), finding.get("confidence_reason", "")) + staged_item["asset_refs"] = sorted({*staged_item.get("asset_refs", []), *[str(ref) for ref in finding.get("asset_refs", []) if str(ref).strip()]}) + staged_item["references"] = [ + *staged_item.get("references", []), + *[item for item in finding.get("references", []) if isinstance(item, dict)], + ] + staged_item["evidence"] = _dedupe_evidence([*staged_item.get("evidence", []), *normalized_evidence]) + staged_item["corroborating_sources"] = _sort_sources([*staged_item.get("corroborating_sources", []), *sources]) + staged_item["metadata"].update({key: value for key, value in (finding.get("metadata") or {}).items() if value not in ("", None, [], {})}) + + normalized: List[Dict[str, Any]] = [] + for finding_group_id, finding in staged.items(): + previous = await db.fetchone( + """ + SELECT first_seen_at, occurrence_count, corroborating_sources_json, analyst_status, retest_status + FROM findings + WHERE owner_id = ? AND finding_group_id = ? + ORDER BY discovered_at DESC + LIMIT 1 + """, + (owner_id, finding_group_id), + ) + prior_sources = [] + if previous and previous.get("corroborating_sources_json"): + try: + prior_sources = json.loads(previous["corroborating_sources_json"]) + except json.JSONDecodeError: + prior_sources = [] + + finding["corroborating_sources"] = _sort_sources([*finding.get("corroborating_sources", []), *prior_sources]) + previous_count = int(previous["occurrence_count"]) if previous and previous.get("occurrence_count") else 0 + local_count = int(finding.get("occurrence_count", 1)) + occurrence_count = previous_count + local_count + finding["occurrence_count"] = occurrence_count + finding["first_seen_at"] = str(previous["first_seen_at"]) if previous and previous.get("first_seen_at") else finding["discovered_at"] + finding["last_seen_at"] = finding["discovered_at"] + finding["analyst_status"] = str(previous["analyst_status"]) if previous and previous.get("analyst_status") else "new" + finding["retest_status"] = str(previous["retest_status"]) if previous and previous.get("retest_status") else "not_requested" + finding["finding_kind"] = _finding_kind_for(finding) + finding["evidence_count"] = len(finding.get("evidence", [])) + fingerprint_score, match_strength = _fingerprint_score(finding) + finding["confidence"] = _compute_confidence( + finding, + corroborating_sources=finding.get("corroborating_sources", []), + occurrence_count=occurrence_count, + evidence=finding.get("evidence", []), + ) + if not finding.get("confidence_reason"): + finding["confidence_reason"] = _build_confidence_reason( + finding_kind=finding["finding_kind"], + evidence_count=finding["evidence_count"], + corroborating_sources=finding.get("corroborating_sources", []), + occurrence_count=occurrence_count, + match_strength=match_strength if fingerprint_score >= 0.45 else "none", + ) + normalized.append(finding) + + normalized.sort( + key=lambda item: ( + -_severity_rank(item.get("severity", "info")), + -(float(item.get("confidence") or 0.0)), + str(item.get("title") or "").lower(), + ) + ) + return normalized + + +def build_finding_groups(findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + groups: Dict[str, Dict[str, Any]] = {} + for finding in findings: + group_id = str(finding.get("finding_group_id") or finding.get("id") or _stable_id("group", finding.get("title"), finding.get("target"))) + current = groups.get(group_id) + if current is None: + groups[group_id] = { + "id": group_id, + "title": finding.get("title"), + "severity": _normalize_severity(finding.get("severity")), + "category": finding.get("category"), + "target": finding.get("target"), + "asset_id": finding.get("asset_id"), + "finding_kind": finding.get("finding_kind", "observation"), + "validated": bool(finding.get("validated")), + "cve": finding.get("cve"), + "cpe": finding.get("cpe"), + "confidence": finding.get("confidence"), + "confidence_reason": finding.get("confidence_reason"), + "first_seen_at": finding.get("first_seen_at") or finding.get("discovered_at"), + "last_seen_at": finding.get("last_seen_at") or finding.get("discovered_at"), + "occurrence_count": int(finding.get("occurrence_count") or 1), + "evidence_count": int(finding.get("evidence_count") or len(finding.get("evidence", []))), + "corroborating_sources": list(finding.get("corroborating_sources", [])), + "analyst_status": finding.get("analyst_status", "new"), + "retest_status": finding.get("retest_status", "not_requested"), + "latest_finding_id": finding.get("id"), + "findings": [finding], + } + continue + + current["validated"] = bool(current.get("validated")) or bool(finding.get("validated")) + if _severity_rank(finding.get("severity", "info")) > _severity_rank(current.get("severity", "info")): + current["severity"] = _normalize_severity(finding.get("severity")) + current["last_seen_at"] = max(str(current.get("last_seen_at") or ""), str(finding.get("last_seen_at") or finding.get("discovered_at") or "")) + current["first_seen_at"] = min(str(current.get("first_seen_at") or ""), str(finding.get("first_seen_at") or finding.get("discovered_at") or "")) + current["occurrence_count"] = max(int(current.get("occurrence_count") or 1), int(finding.get("occurrence_count") or 1)) + current["evidence_count"] = max(int(current.get("evidence_count") or 0), int(finding.get("evidence_count") or len(finding.get("evidence", [])))) + current["corroborating_sources"] = _sort_sources([*current.get("corroborating_sources", []), *finding.get("corroborating_sources", [])]) + current["confidence"] = max(float(current.get("confidence") or 0.0), float(finding.get("confidence") or 0.0)) + current["findings"].append(finding) + + grouped = list(groups.values()) + grouped.sort( + key=lambda item: ( + -_severity_rank(item.get("severity", "info")), + -(float(item.get("confidence") or 0.0)), + str(item.get("title") or "").lower(), + ) + ) + return grouped + + +def build_asset_summary( + findings: List[Dict[str, Any]], + asset_services: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + assets: Dict[str, Dict[str, Any]] = {} + for service in asset_services: + asset_id = str(service.get("asset_id") or _stable_id("asset", service.get("target"), service.get("host"), service.get("port"), service.get("protocol"))) + entry = assets.setdefault( + asset_id, + { + "asset_id": asset_id, + "label": service.get("host") or service.get("target"), + "target": service.get("target"), + "services": [], + "finding_count": 0, + "validated_count": 0, + "highest_severity": "info", + }, + ) + entry["services"].append(service) + + for finding in findings: + asset_id = str(finding.get("asset_id") or _stable_id("asset", finding.get("target"), *(finding.get("asset_refs") or []))) + entry = assets.setdefault( + asset_id, + { + "asset_id": asset_id, + "label": finding.get("target"), + "target": finding.get("target"), + "services": [], + "finding_count": 0, + "validated_count": 0, + "highest_severity": "info", + }, + ) + entry["finding_count"] += 1 + if finding.get("validated"): + entry["validated_count"] += 1 + if _severity_rank(finding.get("severity", "info")) > _severity_rank(entry.get("highest_severity", "info")): + entry["highest_severity"] = _normalize_severity(finding.get("severity")) + + summary = list(assets.values()) + summary.sort(key=lambda item: (-_severity_rank(item.get("highest_severity", "info")), -int(item.get("finding_count", 0)), str(item.get("label") or ""))) + return summary + + +def build_scan_diff(current_findings: List[Dict[str, Any]], previous_findings: List[Dict[str, Any]]) -> Dict[str, Any]: + current = {str(item.get("finding_group_id") or item.get("id")): item for item in current_findings} + previous = {str(item.get("finding_group_id") or item.get("id")): item for item in previous_findings} + + new_groups = [current[key] for key in current.keys() - previous.keys()] + resolved_groups = [previous[key] for key in previous.keys() - current.keys()] + changed_groups = [] + for key in current.keys() & previous.keys(): + before = previous[key] + after = current[key] + if ( + before.get("severity") != after.get("severity") + or bool(before.get("validated")) != bool(after.get("validated")) + or round(float(before.get("confidence") or 0.0), 2) != round(float(after.get("confidence") or 0.0), 2) + ): + changed_groups.append( + { + "before": before, + "after": after, + "group_id": key, + } + ) + + return { + "new": build_finding_groups(new_groups), + "resolved": build_finding_groups(resolved_groups), + "changed": changed_groups, + "summary": { + "new_count": len(new_groups), + "resolved_count": len(resolved_groups), + "changed_count": len(changed_groups), + }, + } diff --git a/backend/secuscan/knowledgebase.py b/backend/secuscan/knowledgebase.py new file mode 100644 index 000000000..21f9f5c82 --- /dev/null +++ b/backend/secuscan/knowledgebase.py @@ -0,0 +1,182 @@ +"""Local vulnerability knowledge-base helpers.""" + +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path +from typing import Any, Dict, List + +from .config import settings + +logger = logging.getLogger(__name__) + + +_SEEDED_CPE_INDEX: Dict[str, List[Dict[str, Any]]] = { + "cpe:/a:nginx:nginx:1.18.0": [ + { + "cve": "CVE-2021-23017", + "severity": "high", + "cvss": 7.7, + "title": "Resolver off-by-one overwrite in nginx", + "description": "Certain nginx resolver configurations before newer releases are vulnerable to a 1-byte memory overwrite.", + "references": [{"source": "NVD", "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-23017"}], + } + ], + "cpe:/a:openbsd:openssh:8.2": [ + { + "cve": "CVE-2020-15778", + "severity": "medium", + "cvss": 6.8, + "title": "Command injection in scp client arguments", + "description": "Affected OpenSSH releases allow command injection in some scp usage patterns.", + "references": [{"source": "NVD", "url": "https://nvd.nist.gov/vuln/detail/CVE-2020-15778"}], + } + ], + "cpe:/a:apache:http_server:2.4.49": [ + { + "cve": "CVE-2021-41773", + "severity": "critical", + "cvss": 9.8, + "title": "Path traversal and file disclosure in Apache HTTP Server", + "description": "Apache HTTP Server 2.4.49 is vulnerable to path traversal and possible remote code execution.", + "references": [{"source": "NVD", "url": "https://nvd.nist.gov/vuln/detail/CVE-2021-41773"}], + } + ], +} + +_PRODUCT_PATTERNS: List[tuple[re.Pattern[str], str]] = [ + (re.compile(r"\bnginx\b", re.I), "cpe:/a:nginx:nginx:{version}"), + (re.compile(r"\bopenssh\b", re.I), "cpe:/a:openbsd:openssh:{version}"), + (re.compile(r"\bapache(?: httpd| http server)?\b", re.I), "cpe:/a:apache:http_server:{version}"), + (re.compile(r"\bwordpress\b", re.I), "cpe:/a:wordpress:wordpress:{version}"), + (re.compile(r"\bdrupal\b", re.I), "cpe:/a:drupal:drupal:{version}"), +] + + +class KnowledgeBase: + """Loads local CPE/CVE intelligence without live network calls.""" + + def __init__(self, data_dir: str | Path | None = None) -> None: + self.data_dir = Path(data_dir or settings.knowledgebase_dir) + self.data_dir.mkdir(parents=True, exist_ok=True) + + def status(self) -> Dict[str, Any]: + entries = self._load_entries() + total_refs = sum(len(v) for v in entries.values()) + feed_files = sorted(path.name for path in self.data_dir.glob("*.json")) + newest_mtime = max((path.stat().st_mtime for path in self.data_dir.glob("*.json")), default=None) + return { + "status": "ready", + "source": "local-json-feeds", + "directory": str(self.data_dir), + "feed_files": feed_files, + "total_cpes": len(entries), + "total_cves": total_refs, + "synced_at": newest_mtime, + } + + def find_vulnerabilities(self, service: str, product: str, version: str) -> Dict[str, Any]: + entries = self._load_entries() + match = self._find_best_cpe_match(entries, service=service, product=product, version=version) + if not match: + return {"cpe": None, "cves": [], "match_strength": "none"} + + candidates = entries.get(match["cpe"], []) + return { + "cpe": match["cpe"], + "cves": list(candidates), + "match_strength": match["match_strength"], + } + + def infer_cpe(self, service: str, product: str, version: str) -> str | None: + entries = self._load_entries() + match = self._find_best_cpe_match(entries, service=service, product=product, version=version) + return match["cpe"] if match else None + + def _normalize_version(self, version: str) -> str: + if not version: + return "" + match = re.search(r"\d+(?:\.\d+){0,3}", version) + return match.group(0) if match else version.strip().lower() + + def _find_best_cpe_match( + self, + entries: Dict[str, List[Dict[str, Any]]], + *, + service: str, + product: str, + version: str, + ) -> Dict[str, str] | None: + haystack = " ".join(part for part in [service, product] if part).strip() + if not haystack: + return None + + normalized_version = self._normalize_version(version) + family_matches: List[tuple[str, str]] = [] + for pattern, template in _PRODUCT_PATTERNS: + if not pattern.search(haystack): + continue + exact_cpe = template.format(version=normalized_version or "unknown") + if normalized_version and exact_cpe in entries: + return {"cpe": exact_cpe, "match_strength": "exact"} + + family_prefix = template.format(version="").rstrip(":") + matching_cpes = [cpe for cpe in entries.keys() if cpe.startswith(family_prefix)] + if not matching_cpes: + continue + + if normalized_version: + strong = self._select_version_match(matching_cpes, normalized_version, same_minor=True) + if strong: + return {"cpe": strong, "match_strength": "strong_fuzzy"} + fuzzy = self._select_version_match(matching_cpes, normalized_version, same_minor=False) + if fuzzy: + return {"cpe": fuzzy, "match_strength": "fuzzy"} + + family_matches.append((matching_cpes[0], "family")) + + if family_matches: + cpe, match_strength = family_matches[0] + return {"cpe": cpe, "match_strength": match_strength} + return None + + def _select_version_match(self, cpes: List[str], normalized_version: str, *, same_minor: bool) -> str | None: + requested_parts = normalized_version.split(".") + for cpe in cpes: + candidate_version = cpe.split(":")[-1] + candidate_parts = candidate_version.split(".") + if candidate_version == normalized_version: + return cpe + if same_minor: + if len(requested_parts) >= 2 and len(candidate_parts) >= 2 and candidate_parts[:2] == requested_parts[:2]: + return cpe + elif candidate_parts and requested_parts and candidate_parts[0] == requested_parts[0]: + return cpe + return None + + def _load_entries(self) -> Dict[str, List[Dict[str, Any]]]: + entries: Dict[str, List[Dict[str, Any]]] = { + key: list(value) for key, value in _SEEDED_CPE_INDEX.items() + } + + for path in sorted(self.data_dir.glob("*.json")): + try: + loaded = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("Failed to load knowledge-base feed %s: %s", path, exc) + continue + + if not isinstance(loaded, dict): + continue + + for cpe, vuln_entries in loaded.items(): + if not isinstance(cpe, str) or not isinstance(vuln_entries, list): + continue + bucket = entries.setdefault(cpe, []) + for item in vuln_entries: + if isinstance(item, dict): + bucket.append(item) + + return entries diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index 36e3208c2..8e06d6638 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -9,9 +9,17 @@ from contextlib import asynccontextmanager from .request_middleware import RequestIDMiddleware -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, PlainTextResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles +from fastapi.exception_handlers import ( + http_exception_handler, + request_validation_exception_handler, +) +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +from .request_context import get_request_id from .config import settings from .auth import init_api_key @@ -22,7 +30,6 @@ from .saved_views import saved_views_router from .workflows import scheduler - logging.basicConfig( level=getattr(logging, settings.log_level), handlers=[ @@ -39,10 +46,8 @@ handler.addFilter(RequestIDFilter()) handler.setFormatter(JSONFormatter()) - logger = logging.getLogger(__name__) - @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan manager""" @@ -68,6 +73,46 @@ async def lifespan(app: FastAPI): await init_plugins(settings.plugins_dir) logger.info("✓ Plugins loaded") + # If docker is enabled, verify and auto-create the restricted docker network + if settings.docker_enabled: + if shutil.which("docker"): + logger.info(f"Docker is enabled. Verifying network '{settings.docker_network}'...") + try: + import subprocess + res = subprocess.run( + ["docker", "network", "inspect", settings.docker_network], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if res.returncode != 0: + logger.info(f"Docker network '{settings.docker_network}' not found. Creating isolated bridge network (ICC disabled)...") + creation_res = subprocess.run( + [ + "docker", "network", "create", + "--driver", "bridge", + "--opt", "com.docker.network.bridge.enable_icc=false", + settings.docker_network + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if creation_res.returncode != 0: + logger.warning("Failed to create isolated bridge network with ICC disabled. Falling back to standard bridge...") + subprocess.run( + ["docker", "network", "create", "--driver", "bridge", settings.docker_network], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + logger.info(f"✓ Docker network '{settings.docker_network}' created (fallback)") + else: + logger.info(f"✓ Docker network '{settings.docker_network}' created with ICC disabled") + else: + logger.info(f"✓ Docker network '{settings.docker_network}' verified") + except Exception as e: + logger.warning(f"Failed to check/create Docker network '{settings.docker_network}': {e}") + else: + logger.warning("Docker sandboxing is enabled but 'docker' executable is not in PATH.") + await scheduler.start() logger.info("✓ Workflow scheduler started") @@ -84,7 +129,6 @@ async def lifespan(app: FastAPI): await scheduler.stop() logger.info("✓ Shutdown complete") - # Create FastAPI application app = FastAPI( title="SecuScan API", @@ -111,7 +155,6 @@ async def redirect_api_openapi(): from fastapi.responses import RedirectResponse return RedirectResponse(url="/openapi.json") - # CORS middleware cors_allow_all = "*" in settings.cors_allowed_origins if cors_allow_all and settings.cors_allow_credentials: @@ -129,6 +172,33 @@ async def redirect_api_openapi(): ) app.add_middleware(RequestIDMiddleware) +@app.exception_handler(StarletteHTTPException) +async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException): + response = await http_exception_handler(request, exc) + response.headers["X-Request-ID"] = getattr(request.state, "request_id", get_request_id()) + return response + + +@app.exception_handler(RequestValidationError) +async def custom_validation_exception_handler(request: Request, exc: RequestValidationError): + response = await request_validation_exception_handler(request, exc) + response.headers["X-Request-ID"] = getattr(request.state, "request_id", get_request_id()) + return response + +@app.exception_handler(Exception) +async def custom_unhandled_exception_handler(request: Request, exc: Exception): + logger.exception("Unhandled exception in request lifecycle") + + if settings.debug: + import traceback + html = f"<html><body><h1>500 Internal Server Error</h1><pre>{traceback.format_exc()}</pre></body></html>" + response = HTMLResponse(html, status_code=500) + else: + response = PlainTextResponse("Internal Server Error", status_code=500) + + response.headers["X-Request-ID"] = getattr(request.state, "request_id", get_request_id()) + return response + # Include API routes app.include_router(router) app.include_router(saved_views_router) @@ -141,6 +211,7 @@ async def health_check(): import platform import sys + logger.info("Health check endpoint accessed") return { "status": "operational", "version": "0.1.0-alpha", @@ -151,7 +222,6 @@ async def health_check(): } } - # Root endpoint @app.get("/") async def root(): @@ -164,7 +234,6 @@ async def root(): "legal_notice": "For authorized testing only. Unauthorized scanning may be illegal." } - def main(): """Main entry point""" import uvicorn @@ -188,6 +257,5 @@ def main(): log_level=settings.log_level.lower() ) - if __name__ == "__main__": main() diff --git a/backend/secuscan/migrations/003_add_owner_id.sql b/backend/secuscan/migrations/003_add_owner_id.sql new file mode 100644 index 000000000..283e7e17f --- /dev/null +++ b/backend/secuscan/migrations/003_add_owner_id.sql @@ -0,0 +1,26 @@ +-- Migration: 003_add_owner_id +-- Introduces per-user / per-workspace ownership for tasks, findings, and +-- reports to close the Broken Object Level Authorization (BOLA) gap where any +-- caller could read, delete, or export any task/report by guessing its ID +-- (issue #401). +-- +-- The owner_id columns themselves are added idempotently in database.py +-- (_create_schema), using PRAGMA table_info checks so re-running startup is +-- safe — SQLite has no "ALTER TABLE ... ADD COLUMN IF NOT EXISTS". This file +-- only contains statements that are safe to re-run on every startup: +-- +-- 1. A defensive backfill of any NULL owner_id to the shared default owner. +-- (New columns are added as NOT NULL DEFAULT 'default', so existing rows +-- are already backfilled; this guards against rows created by an older +-- build that may have added the column as nullable.) +-- 2. Indexes used to keep owner-scoped list queries fast. +-- +-- Keep the 'default' literal in sync with auth.DEFAULT_OWNER_ID. + +UPDATE tasks SET owner_id = 'default' WHERE owner_id IS NULL; +UPDATE findings SET owner_id = 'default' WHERE owner_id IS NULL; +UPDATE reports SET owner_id = 'default' WHERE owner_id IS NULL; + +CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner_id); +CREATE INDEX IF NOT EXISTS idx_findings_owner ON findings(owner_id); +CREATE INDEX IF NOT EXISTS idx_reports_owner ON reports(owner_id); diff --git a/backend/secuscan/migrations/006_workflow_history.sql b/backend/secuscan/migrations/006_workflow_history.sql new file mode 100644 index 000000000..00082c208 --- /dev/null +++ b/backend/secuscan/migrations/006_workflow_history.sql @@ -0,0 +1,44 @@ +-- Migration: 006_workflow_history +-- Adds two tables for workflow governance: +-- +-- workflow_versions — a full snapshot of the workflow definition every time +-- PATCH /workflows/:id modifies it, preserving the history needed for rollback. +-- version_number is a per-workflow monotonic counter so rollback targets can be +-- identified by a stable integer. definition_json stores the complete snapshot +-- (name, schedule_seconds, enabled, steps) so a rollback is a self-contained +-- restore and does not depend on diff-chaining. +-- +-- workflow_runs — one row per run_workflow_once() invocation, recording which +-- version was active at run time, the task IDs that were queued, and the +-- final status (queued → completed | failed | cancelled). +-- A background finalizer polls task statuses and updates completed_at when +-- all tasks reach a terminal state. + +CREATE TABLE IF NOT EXISTS workflow_versions ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL, + definition_json TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + created_by TEXT NOT NULL DEFAULT 'system', + UNIQUE(workflow_id, version_number) +); + +CREATE INDEX IF NOT EXISTS idx_wf_versions_workflow ON workflow_versions(workflow_id, version_number DESC); + +CREATE TABLE IF NOT EXISTS workflow_runs ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + version_id TEXT REFERENCES workflow_versions(id) ON DELETE SET NULL, + version_number INTEGER, + triggered_by TEXT NOT NULL DEFAULT 'manual', + status TEXT NOT NULL DEFAULT 'queued', + task_ids_json TEXT NOT NULL DEFAULT '[]', + started_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + completed_at TIMESTAMP, + error_message TEXT +); + +CREATE INDEX IF NOT EXISTS idx_wf_runs_workflow ON workflow_runs(workflow_id, started_at DESC); +CREATE INDEX IF NOT EXISTS idx_wf_runs_status ON workflow_runs(status); +CREATE INDEX IF NOT EXISTS idx_wf_runs_version_id ON workflow_runs(version_id); diff --git a/backend/secuscan/models.py b/backend/secuscan/models.py index 829855f70..b6cb61c03 100644 --- a/backend/secuscan/models.py +++ b/backend/secuscan/models.py @@ -26,6 +26,22 @@ class TaskStatus(str, Enum): CANCELLED = "cancelled" +class SandboxConfig(BaseModel): + """Resource constraints applied to every plugin subprocess execution""" + timeout_seconds: int = Field(default=120, description="Max wall-clock seconds before SIGTERM") + max_memory_mb: int = Field(default=512, description="Max virtual memory in MB (RLIMIT_AS on Linux)") + max_output_bytes: int = Field(default=5_242_880, description="Max bytes captured from stdout/stderr") + allow_network: bool = Field(default=True, description="Whether subprocess can make network calls") + + +class SandboxViolation(Exception): + """Raised when sandbox constraints are violated.""" + + def __init__(self, reason: str): + super().__init__(reason) + self.reason = reason + + class ScanPhase(str, Enum): """Granular scan phase for progress display""" QUEUED = "queued" @@ -47,6 +63,70 @@ class PluginFieldType(str, Enum): KEYVALUE = "keyvalue" +class PluginImplementationStatus(str, Enum): + """How production-ready a plugin integration currently is.""" + NATIVE = "native" + INTEGRATED = "integrated" + PLACEHOLDER = "placeholder" + + +class ValidationMode(str, Enum): + """How far SecuScan is allowed to validate a suspected issue.""" + DETECT_ONLY = "detect_only" + PROOF = "proof" + CONTROLLED_EXTRACT = "controlled_extract" + + +class EvidenceLevel(str, Enum): + """How much evidence the platform should retain per finding.""" + MINIMAL = "minimal" + STANDARD = "standard" + FULL = "full" + + +class FindingKind(str, Enum): + """Normalized finding classification.""" + OBSERVATION = "observation" + SUSPECTED_ISSUE = "suspected_issue" + VALIDATED_ISSUE = "validated_issue" + + +class AnalystStatus(str, Enum): + """Analyst review state for a finding.""" + NEW = "new" + CONFIRMED = "confirmed" + NEEDS_REVIEW = "needs_review" + FALSE_POSITIVE = "false_positive" + ACCEPTED_RISK = "accepted_risk" + FIXED = "fixed" + + +class RetestStatus(str, Enum): + """Retest lifecycle state for a finding.""" + NOT_REQUESTED = "not_requested" + PENDING = "pending" + PASSED = "passed" + FAILED = "failed" + + +class ExecutionContext(BaseModel): + """Task/workflow execution policy selected by the operator.""" + target_policy_id: Optional[str] = None + scan_profile: str = "standard" + credential_profile_id: Optional[str] = None + session_profile_id: Optional[str] = None + validation_mode: ValidationMode = ValidationMode.PROOF + evidence_level: EvidenceLevel = EvidenceLevel.STANDARD + + +class WorkflowStep(BaseModel): + """Single workflow step.""" + plugin_id: str + inputs: Dict[str, Any] + preset: Optional[str] = None + execution_context: ExecutionContext = Field(default_factory=ExecutionContext) + + class PluginField(BaseModel): """Plugin input field definition""" id: str @@ -80,6 +160,9 @@ class PluginMetadata(BaseModel): output: Dict[str, Any] safety: Dict[str, Any] capabilities: Optional[List[str]] = None + implementation_status: Optional[PluginImplementationStatus] = None + supports_authenticated_crawling: bool = False + supports_session_reuse: bool = False learning: Optional[Dict[str, Any]] = None dependencies: Optional[Dict[str, List[str]]] = None docker_image: Optional[str] = None @@ -94,6 +177,7 @@ class TaskCreateRequest(BaseModel): preset: Optional[str] = None inputs: Dict[str, Any] consent_granted: bool = False + execution_context: ExecutionContext = Field(default_factory=ExecutionContext) class TaskResponse(BaseModel): @@ -109,6 +193,7 @@ class TaskResponse(BaseModel): duration_seconds: Optional[float] = None inputs: Optional[Dict[str, Any]] = None preset: Optional[str] = None + execution_context: ExecutionContext = Field(default_factory=ExecutionContext) error_message: Optional[str] = None exit_code: Optional[int] = None @@ -129,9 +214,27 @@ class Finding(BaseModel): metadata: Dict[str, Any] = Field(default_factory=dict) exploitability: Optional[float] = None confidence: Optional[float] = None + validated: bool = False + validation_method: Optional[str] = None + confidence_reason: Optional[str] = None + evidence: List[Dict[str, Any]] = Field(default_factory=list) + asset_refs: List[str] = Field(default_factory=list) + service_fingerprint: Optional[str] = None + cpe: Optional[str] = None + references: List[Dict[str, Any]] = Field(default_factory=list) asset_exposure: Optional[str] = None risk_score: Optional[float] = None risk_factors: List[Dict[str, Any]] = Field(default_factory=list) + finding_kind: FindingKind = FindingKind.OBSERVATION + finding_group_id: Optional[str] = None + asset_id: Optional[str] = None + first_seen_at: Optional[datetime] = None + last_seen_at: Optional[datetime] = None + occurrence_count: int = 1 + corroborating_sources: List[str] = Field(default_factory=list) + evidence_count: int = 0 + analyst_status: AnalystStatus = AnalystStatus.NEW + retest_status: RetestStatus = RetestStatus.NOT_REQUESTED class TaskResult(BaseModel): @@ -143,10 +246,14 @@ class TaskResult(BaseModel): timestamp: datetime duration_seconds: Optional[float] status: TaskStatus + execution_context: ExecutionContext = Field(default_factory=ExecutionContext) summary: List[str] = [] severity_counts: Dict[str, int] = Field(default_factory=dict) findings: List[Finding] = Field(default_factory=list) + finding_groups: List[Dict[str, Any]] = Field(default_factory=list) + asset_summary: List[Dict[str, Any]] = Field(default_factory=list) + scan_diff: Dict[str, Any] = Field(default_factory=dict) structured: Dict[str, Any] = Field(default_factory=dict) raw_output_path: Optional[str] = None raw_output_excerpt: Optional[str] = None diff --git a/backend/secuscan/network_policy.py b/backend/secuscan/network_policy.py new file mode 100644 index 000000000..3f03c97b1 --- /dev/null +++ b/backend/secuscan/network_policy.py @@ -0,0 +1,463 @@ +""" +Network egress policy enforcement for scanners. + +Implements deny-by-default network access control with configurable +allowlist/denylist policies. Supports both IPv4 and IPv6. +""" + +import ipaddress +import logging +import asyncio +import socket +from typing import List, Tuple, Optional, Dict, Any +from urllib.parse import urlparse +from enum import Enum +from dataclasses import dataclass, asdict +from datetime import datetime +from functools import lru_cache +from pathlib import Path + +logger = logging.getLogger(__name__) + +class PolicyAction(Enum): + """Network policy decision outcome""" + ALLOW = "allow" + DENY = "deny" + +@dataclass +class NetworkPolicy: + """Single network access policy rule""" + cidr: str # Network in CIDR notation + action: PolicyAction # Allow or deny + reason: str # Why this rule exists + created_at: datetime # When rule was added + expires_at: Optional[datetime] = None # Optional expiration + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for logging""" + return { + "cidr": self.cidr, + "action": self.action.value, + "reason": self.reason, + "created_at": self.created_at.isoformat(), + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + } + +@dataclass +class AuditLogEntry: + """Network access audit trail entry""" + timestamp: datetime + plugin_id: str + task_id: str + action: PolicyAction + dest_ip: str + dest_port: int + dest_hostname: Optional[str] + policy_matched: str # CIDR that caused decision + reason: str + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON logging""" + return { + "timestamp": self.timestamp.isoformat(), + "plugin_id": self.plugin_id, + "task_id": self.task_id, + "action": self.action.value, + "dest_ip": self.dest_ip, + "dest_port": self.dest_port, + "dest_hostname": self.dest_hostname, + "policy_matched": self.policy_matched, + "reason": self.reason, + } + +class NetworkPolicyEngine: + """ + Enforce network access policies for scanners. + + Logic: + 1. Check explicit denylist (highest priority, fails fast) + 2. Check explicit allowlist (allows if matched) + 3. Default deny (no match = blocked) + """ + + def __init__(self, audit_log_path: str = "/var/log/secuscan/network.audit.log"): + self.allowlist: List[Tuple[ipaddress.ip_network, NetworkPolicy]] = [] + self.denylist: List[Tuple[ipaddress.ip_network, NetworkPolicy]] = [] + self.audit_log_path = audit_log_path + self.audit_entries: List[AuditLogEntry] = [] + + # Create audit log file + self._init_audit_log() + + def _init_audit_log(self): + """Initialize audit log file with header""" + try: + # Ensure the directory exists + Path(self.audit_log_path).parent.mkdir(parents=True, exist_ok=True) + with open(self.audit_log_path, 'a') as f: + if f.tell() == 0: # Empty file + f.write("# SecuScan Network Audit Log\n") + f.write(f"# Started: {datetime.now().isoformat()}\n") + except IOError as e: + logger.error(f"Failed to initialize audit log: {e}") + + def add_allow_rule( + self, + cidr: str, + reason: str = "Operator configured", + expires_at: Optional[datetime] = None + ) -> None: + """ + Add a network to the allowlist. + + Args: + cidr: Network in CIDR notation (e.g., "10.0.0.0/8") + reason: Human-readable reason for this rule + expires_at: Optional expiration timestamp + """ + try: + net = ipaddress.ip_network(cidr, strict=False) + policy = NetworkPolicy( + cidr=cidr, + action=PolicyAction.ALLOW, + reason=reason, + created_at=datetime.now(), + expires_at=expires_at, + ) + self.allowlist.append((net, policy)) + logger.info(f"Added allow rule for {cidr}: {reason}") + except ValueError as e: + logger.error(f"Invalid CIDR in allowlist: {cidr}: {e}") + raise + + def add_deny_rule( + self, + cidr: str, + reason: str = "System blocked", + expires_at: Optional[datetime] = None + ) -> None: + """ + Add a network to the denylist. + + Args: + cidr: Network in CIDR notation + reason: Human-readable reason for this rule + expires_at: Optional expiration timestamp + """ + try: + net = ipaddress.ip_network(cidr, strict=False) + policy = NetworkPolicy( + cidr=cidr, + action=PolicyAction.DENY, + reason=reason, + created_at=datetime.now(), + expires_at=expires_at, + ) + self.denylist.append((net, policy)) + logger.info(f"Added deny rule for {cidr}: {reason}") + except ValueError as e: + logger.error(f"Invalid CIDR in denylist: {cidr}: {e}") + raise + + def check_access( + self, + dest_ip: str, + dest_port: int = 0, + plugin_id: str = "unknown", + task_id: str = "unknown", + dest_hostname: Optional[str] = None, + ) -> Tuple[bool, str, NetworkPolicy]: + """ + Check if outbound connection is allowed. + + Args: + dest_ip: Destination IP address + dest_port: Destination port (informational) + plugin_id: Plugin making the connection + task_id: Task ID for audit context + dest_hostname: Optional resolved hostname + + Returns: + Tuple of (allowed: bool, decision_reason: str, matched_policy: NetworkPolicy) + """ + # Clean dest_ip if it is a full URL, has a port, or has brackets + original_dest_ip = dest_ip + target_host = dest_ip.strip() + if "://" in target_host: + try: + from urllib.parse import urlparse + parsed = urlparse(target_host) + if parsed.scheme in {"http", "https", "ws", "wss"}: + if parsed.hostname: + target_host = parsed.hostname + except Exception: + pass + + if ":" in target_host: + if target_host.startswith("["): + if "]" in target_host: + parts = target_host.rsplit("]", 1) + host_part = parts[0] + "]" + port_part = parts[1] + if port_part.startswith(":") and port_part[1:].isdigit(): + target_host = host_part + elif target_host.count(":") == 1: + parts = target_host.rsplit(":", 1) + if parts[1].isdigit(): + target_host = parts[0] + if target_host.startswith("[") and target_host.endswith("]"): + target_host = target_host[1:-1] + + try: + ip = ipaddress.ip_address(target_host) + dest_ip = str(ip) + except ValueError: + # Try resolving hostname to IP if target_host is a domain name + try: + resolved = socket.gethostbyname(target_host) + ip = ipaddress.ip_address(resolved) + if not dest_hostname: + dest_hostname = target_host + dest_ip = resolved + except Exception: + reason = f"Invalid IP address format: {original_dest_ip}" + entry = AuditLogEntry( + timestamp=datetime.now(), + plugin_id=plugin_id, + task_id=task_id, + action=PolicyAction.DENY, + dest_ip=original_dest_ip, + dest_port=dest_port, + dest_hostname=dest_hostname, + policy_matched="invalid_ip", + reason=reason, + ) + self._log_audit_entry(entry) + return False, reason, None + + # ═ Step 1: Check denylist (highest priority) ═ + for net, policy in self.denylist: + if self._is_expired(policy): + continue + if ip in net: + reason = f"Blocked by denylist rule: {policy.reason} (matched: {policy.cidr})" + entry = AuditLogEntry( + timestamp=datetime.now(), + plugin_id=plugin_id, + task_id=task_id, + action=PolicyAction.DENY, + dest_ip=dest_ip, + dest_port=dest_port, + dest_hostname=dest_hostname, + policy_matched=policy.cidr, + reason=reason, + ) + self._log_audit_entry(entry) + return False, reason, policy + + # ═ Step 2: Check allowlist ═ + for net, policy in self.allowlist: + if self._is_expired(policy): + continue + if ip in net: + reason = f"Allowed by allowlist rule: {policy.reason} (matched: {policy.cidr})" + entry = AuditLogEntry( + timestamp=datetime.now(), + plugin_id=plugin_id, + task_id=task_id, + action=PolicyAction.ALLOW, + dest_ip=dest_ip, + dest_port=dest_port, + dest_hostname=dest_hostname, + policy_matched=policy.cidr, + reason=reason, + ) + self._log_audit_entry(entry) + return True, reason, policy + + # ═ Step 3: Default deny ═ + reason = "Denied by default (no matching allow rule)" + deny_policy = NetworkPolicy( + cidr="0.0.0.0/0", + action=PolicyAction.DENY, + reason="Default deny policy", + created_at=datetime.now(), + ) + entry = AuditLogEntry( + timestamp=datetime.now(), + plugin_id=plugin_id, + task_id=task_id, + action=PolicyAction.DENY, + dest_ip=dest_ip, + dest_port=dest_port, + dest_hostname=dest_hostname, + policy_matched="default", + reason=reason, + ) + self._log_audit_entry(entry) + return False, reason, deny_policy + + def _is_expired(self, policy: NetworkPolicy) -> bool: + """Check if a policy has expired""" + if policy.expires_at is None: + return False + return datetime.now() > policy.expires_at + + def _log_audit_entry(self, entry: AuditLogEntry) -> None: + """Log audit entry to file and memory""" + self.audit_entries.append(entry) + + try: + with open(self.audit_log_path, 'a') as f: + import json + f.write(json.dumps(entry.to_dict()) + "\n") + except IOError as e: + logger.error(f"Failed to write audit log: {e}") + + def get_audit_entries( + self, + plugin_id: Optional[str] = None, + action: Optional[PolicyAction] = None, + limit: int = 1000 + ) -> List[AuditLogEntry]: + """ + Retrieve audit log entries with optional filtering. + + Args: + plugin_id: Filter by plugin (optional) + action: Filter by action (ALLOW or DENY) + limit: Maximum number of entries to return + + Returns: + List of matching audit entries + """ + entries = self.audit_entries + + if plugin_id: + entries = [e for e in entries if e.plugin_id == plugin_id] + + if action: + entries = [e for e in entries if e.action == action] + + return entries[-limit:] # Return most recent N + + def validate_egress_target(self, host: str, port: int = 443) -> Tuple[bool, str]: + """Validate an outbound webhook/egress destination against network policy. + + Args: + host: Hostname to validate + port: Destination port + + Returns: + Tuple of (allowed, reason) + """ + target_host = host + if "://" in target_host: + try: + parsed = urlparse(target_host) + if parsed.hostname: + target_host = parsed.hostname + except Exception: + pass + + try: + ip = ipaddress.ip_address(target_host) + except ValueError: + try: + resolved = socket.gethostbyname(target_host) + ip = ipaddress.ip_address(resolved) + except Exception: + return False, f"Could not resolve host: {target_host}" + + for net, policy in self.denylist: + if not self._is_expired(policy) and ip in net: + return False, f"Destination blocked by policy: {policy.reason}" + + for net, policy in self.allowlist: + if not self._is_expired(policy) and ip in net: + return True, "" + + return False, "Destination denied by default policy" + + def export_audit_log(self, format: str = "json") -> str: + """ + Export audit log in specified format. + + Args: + format: "json" or "csv" + + Returns: + Formatted audit log string + """ + if format == "csv": + import csv + import io + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=[ + 'timestamp', 'plugin_id', 'task_id', 'action', + 'dest_ip', 'dest_port', 'dest_hostname', 'policy_matched', 'reason' + ]) + writer.writeheader() + for entry in self.audit_entries: + writer.writerow(entry.to_dict()) + return output.getvalue() + else: # JSON + import json + return json.dumps([e.to_dict() for e in self.audit_entries], indent=2) + +# Global policy engine instance +_policy_engine: Optional[NetworkPolicyEngine] = None + +def get_policy_engine() -> NetworkPolicyEngine: + """Get or create global policy engine singleton""" + global _policy_engine + if _policy_engine is None: + from .config import settings + _policy_engine = NetworkPolicyEngine( + audit_log_path=settings.network_audit_log_file + ) + _init_default_policies(_policy_engine) + return _policy_engine + +def _init_default_policies(engine: NetworkPolicyEngine) -> None: + """Initialize default security policies""" + from .config import settings + + # Add operator-configured denylist (always enforced) + for cidr in settings.network_denylist: + try: + engine.add_deny_rule(cidr, reason="Operator configured denylist") + except ValueError: + logger.warning(f"Skipping invalid denylist CIDR: {cidr}") + + # Add operator-configured allowlist + for cidr in settings.network_allowlist: + try: + engine.add_allow_rule(cidr, reason="Operator configured allowlist") + except ValueError: + logger.warning(f"Skipping invalid allowlist CIDR: {cidr}") + + # When no explicit allowlist is configured, allow public egress while still + # blocking denylisted ranges (private, metadata, loopback, link-local, ULA). + # The denylist is checked first (step 1 in check_access), so these implicit + # allow rules never override an explicit deny. + if not settings.network_allowlist: + engine.add_allow_rule( + "0.0.0.0/0", + reason="Default public egress (no explicit allowlist configured)", + ) + engine.add_allow_rule( + "::/0", + reason="Default public egress (no explicit allowlist configured)", + ) + logger.info( + "No SECUSCAN_NETWORK_ALLOWLIST configured. Default policy: public egress " + "allowed; denylisted ranges (private, metadata, loopback, link-local, ULA) " + "still blocked." + ) + else: + logger.info( + "Custom network allowlist configured with %d entries. " + "Deny-by-default egress policy is active.", + len(settings.network_allowlist), + ) diff --git a/backend/secuscan/notification_service.py b/backend/secuscan/notification_service.py new file mode 100644 index 000000000..a6d57e10b --- /dev/null +++ b/backend/secuscan/notification_service.py @@ -0,0 +1,645 @@ +""" +Notification delivery service for high-severity findings. + +Evaluates active rules, deduplicates deliveries, redacts alert payloads, +and records outcomes in notification_history. Webhook delivery is live; +email is a logged placeholder until SMTP is added. +""" + +from __future__ import annotations + +import json +import html +import logging +import socket +import ssl +import uuid +import ipaddress +import smtplib +import asyncio +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from dataclasses import dataclass +from types import TracebackType +from typing import Any, Dict, Iterable, List, Optional, Tuple, Type +from urllib.parse import urlparse + +import httpx +import httpcore + +from .database import Database +from .models import NotificationChannelType, NotificationDeliveryStatus +from .redaction import redact_dict, redact_inputs + +logger = logging.getLogger(__name__) + +# Lower rank = more severe. A finding meets the threshold when its rank is +# less than or equal to the rule threshold rank. +_SEVERITY_RANK: Dict[str, int] = { + "critical": 0, + "high": 1, + "medium": 2, + "low": 3, + "info": 4, +} + +_WEBHOOK_TIMEOUT_SECONDS = 10.0 +_WEBHOOK_CONNECT_TIMEOUT_SECONDS = 5.0 +_USER_AGENT = "SecuScan-Notifications/1.0" + +SOCKET_OPTION = Tuple[int, int, int | bytes] + + +class _PinnedIPNetworkStream(httpcore.AsyncNetworkStream): + """Wraps a network stream so that ``start_tls`` always uses the original + hostname for SNI / certificate verification, even when the TCP connection + was made to a different (resolved-IP) address.""" + + __slots__ = ("_inner", "_original_hostname") + + def __init__( + self, inner: httpcore.AsyncNetworkStream, original_hostname: str + ) -> None: + self._inner = inner + self._original_hostname = original_hostname + + async def read(self, max_bytes: int, timeout: float | None = None) -> bytes: + return await self._inner.read(max_bytes, timeout) + + async def write(self, buffer: bytes, timeout: float | None = None) -> None: + await self._inner.write(buffer, timeout) + + async def aclose(self) -> None: + await self._inner.aclose() + + async def start_tls( + self, + ssl_context: ssl.SSLContext, + server_hostname: str | None = None, + timeout: float | None = None, + ) -> httpcore.AsyncNetworkStream: + return await self._inner.start_tls( + ssl_context=ssl_context, + server_hostname=self._original_hostname, + timeout=timeout, + ) + + def get_extra_info(self, info: str) -> Any: + return self._inner.get_extra_info(info) + + +class _PinnedIPNetworkBackend(httpcore.AsyncNetworkBackend): + """Network backend that connects TCP to the validated (pinned) IP while + preserving the original hostname for subsequent TLS negotiation.""" + + __slots__ = ("_resolved_ip", "_original_hostname", "_default_backend") + + def __init__(self, resolved_ip: str, original_hostname: str) -> None: + self._resolved_ip = resolved_ip + self._original_hostname = original_hostname + from httpcore._backends.auto import AutoBackend + + self._default_backend = AutoBackend() + + async def connect_tcp( + self, + host: str, + port: int, + timeout: float | None = None, + local_address: str | None = None, + socket_options: Iterable[SOCKET_OPTION] | None = None, + ) -> httpcore.AsyncNetworkStream: + stream = await self._default_backend.connect_tcp( + host=self._resolved_ip, + port=port, + timeout=timeout, + local_address=local_address, + socket_options=socket_options, + ) + return _PinnedIPNetworkStream(stream, self._original_hostname) + + async def connect_unix_socket( + self, + path: str, + timeout: float | None = None, + socket_options: Iterable[SOCKET_OPTION] | None = None, + ) -> httpcore.AsyncNetworkStream: + return await self._default_backend.connect_unix_socket( + path, timeout, socket_options + ) + + async def sleep(self, seconds: float) -> None: + await self._default_backend.sleep(seconds) + + +class _PinnedIPTransport(httpx.AsyncBaseTransport): + """httpx transport that wraps an ``httpcore.AsyncConnectionPool`` using a + ``_PinnedIPNetworkBackend`` so that every outgoing connection is pinned to a + pre-validated IP address while the original hostname is used for TLS.""" + + __slots__ = ("_pool",) + + def __init__(self, resolved_ip: str, original_hostname: str) -> None: + import httpcore as _httpcore + + backend = _PinnedIPNetworkBackend(resolved_ip, original_hostname) + self._pool = _httpcore.AsyncConnectionPool(network_backend=backend) + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + import httpcore as _httpcore + + req = _httpcore.Request( + method=request.method, + url=_httpcore.URL( + scheme=request.url.raw_scheme, + host=request.url.raw_host, + port=request.url.port, + target=request.url.raw_path, + ), + headers=request.headers.raw, + content=request.stream, + extensions=request.extensions, + ) + resp = await self._pool.handle_async_request(req) + content = b"" + async for chunk in resp.stream: + content += chunk + return httpx.Response( + status_code=resp.status, + headers=resp.headers, + content=content, + extensions=resp.extensions, + ) + + async def __aenter__(self) -> _PinnedIPTransport: + await self._pool.__aenter__() + return self + + async def __aexit__( + self, + exc_type: Type[BaseException] | None = None, + exc_value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: + await self._pool.__aexit__(exc_type, exc_value, traceback) + + async def aclose(self) -> None: + await self._pool.aclose() + + +@dataclass(frozen=True) +class DeliveryResult: + """Outcome of a single rule delivery attempt for one finding.""" + + rule_id: str + finding_id: str + status: NotificationDeliveryStatus + skipped: bool = False + error_message: Optional[str] = None + + +def severity_meets_threshold(finding_severity: str, rule_threshold: str) -> bool: + """Return True when finding severity is at or above the rule threshold.""" + finding_rank = _SEVERITY_RANK.get(str(finding_severity).lower()) + threshold_rank = _SEVERITY_RANK.get(str(rule_threshold).lower()) + if finding_rank is None or threshold_rank is None: + return False + return finding_rank <= threshold_rank + + +def build_alert_payload( + finding: Dict[str, Any], + rule: Dict[str, Any], +) -> Dict[str, Any]: + """Build a redacted JSON-serializable alert payload for outbound channels.""" + metadata: Dict[str, Any] = {} + raw_metadata = finding.get("metadata_json") + if raw_metadata: + try: + parsed = json.loads(raw_metadata) + if isinstance(parsed, dict): + metadata = redact_inputs(parsed) + except (TypeError, json.JSONDecodeError): + metadata = {"raw": str(raw_metadata)} + + payload = { + "event": "finding.alert", + "rule": { + "id": rule.get("id"), + "name": rule.get("name"), + "severity_threshold": rule.get("severity_threshold"), + "channel_type": rule.get("channel_type"), + }, + "finding": { + "id": finding.get("id"), + "task_id": finding.get("task_id"), + "plugin_id": finding.get("plugin_id"), + "title": finding.get("title"), + "category": finding.get("category"), + "severity": finding.get("severity"), + "target": finding.get("target"), + "description": finding.get("description"), + "remediation": finding.get("remediation"), + "metadata": metadata, + }, + } + return redact_dict(payload) + + +async def was_already_delivered( + db: Database, + rule_id: str, + finding_id: str, +) -> bool: + """Return True when this rule already successfully notified this finding.""" + row = await db.fetchone( + """ + SELECT id FROM notification_history + WHERE rule_id = ? AND finding_id = ? AND status = ? + LIMIT 1 + """, + (rule_id, finding_id, NotificationDeliveryStatus.SUCCESS.value), + ) + return row is not None + + +async def record_delivery( + db: Database, + rule_id: str, + finding_id: str, + status: NotificationDeliveryStatus, + error_message: Optional[str] = None, +) -> str: + """Persist a delivery attempt and return the history row id.""" + history_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO notification_history (id, rule_id, finding_id, status, error_message) + VALUES (?, ?, ?, ?, ?) + """, + ( + history_id, + rule_id, + finding_id, + status.value, + error_message, + ), + ) + return history_id + + +async def send_webhook( + target_url: str, payload: Dict[str, Any] +) -> tuple[bool, Optional[str]]: + """POST a redacted alert payload to a webhook URL with SSRF protections. + + Always resolves the target hostname and validates every returned IP against + the configured SECUSCAN_NOTIFICATION_BLOCKED_IP_RANGES, independent of the + general enforce_network_policy setting. + + The address actually contacted is the validated IP (not the hostname) to + prevent DNS-rebinding / TOCTOU attacks. How this is achieved differs by + scheme so that TLS verification is not broken: + + * **http**: the URL is rewritten to the resolved IP and the ``Host`` header + is set to the original hostname. Plain HTTP has no TLS so there is no + certificate-verification concern. + + * **https**: a custom ``_PinnedIPTransport`` binds the TCP connection to the + validated IP while the original hostname is preserved for TLS SNI and + certificate verification, keeping the original hostname in the URL. + """ + from .config import settings + from urllib.parse import urlparse, urlunparse + + parsed = urlparse(target_url) + hostname = parsed.hostname + if not hostname: + return False, "Webhook URL has no hostname" + + # Resolve and validate every address the hostname may return. + try: + addrs = socket.getaddrinfo( + hostname, parsed.port or 443, proto=socket.IPPROTO_TCP + ) + except OSError: + return False, "Webhook URL hostname could not be resolved" + + validated_ips: list[str] = [] + for family, _stype, _proto, _cname, sockaddr in addrs: + ip_str = sockaddr[0] + try: + ip = ipaddress.ip_address(ip_str) + except ValueError: + continue + blocked = False + for blocked_cidr in settings.notification_blocked_ip_ranges: + try: + if ip in ipaddress.ip_network(blocked_cidr, strict=False): + blocked = True + break + except ValueError: + continue + if blocked: + return False, f"Webhook target resolves to blocked IP range: {blocked_cidr}" + validated_ips.append(ip_str) + + if not validated_ips: + return False, "Webhook URL did not resolve to any valid IP addresses" + + timeout = httpx.Timeout( + timeout=_WEBHOOK_TIMEOUT_SECONDS, + connect=_WEBHOOK_CONNECT_TIMEOUT_SECONDS, + ) + + resolved_ip = validated_ips[0] + scheme = parsed.scheme + + # -- Build the request URL and choose the pinning strategy -------------- + if scheme == "https": + # Use a custom transport that connects to the pinned IP while keeping + # the original hostname for TLS SNI and certificate verification. + transport = _PinnedIPTransport(resolved_ip, hostname) + request_url = target_url + extra_headers: dict[str, str] = {} + else: + # Rewrite the URL to the resolved IP and set the Host header. + # This is safe for plain HTTP because there is no TLS handshake. + new_netloc = f"[{resolved_ip}]" if ":" in resolved_ip else resolved_ip + if parsed.port: + new_netloc = f"{new_netloc}:{parsed.port}" + request_url = urlunparse( + ( + scheme, + new_netloc, + parsed.path, + parsed.params, + parsed.query, + parsed.fragment, + ) + ) + transport = None + extra_headers = {"Host": hostname} + + try: + client_kwargs: dict[str, Any] = { + "timeout": timeout, + "follow_redirects": False, + } + if transport is not None: + client_kwargs["transport"] = transport + + async with httpx.AsyncClient(**client_kwargs) as client: + headers = { + "Content-Type": "application/json", + "User-Agent": _USER_AGENT, + } + headers.update(extra_headers) + response = await client.post( + request_url, + json=payload, + headers=headers, + ) + + if response.status_code >= 400: + return False, f"Webhook returned HTTP {response.status_code}" + + if response.status_code in (301, 302, 303, 307, 308): + redirect_url = response.headers.get("location", "") + if redirect_url: + from urllib.parse import urlparse + + parsed_redirect = urlparse(redirect_url) + if parsed_redirect.hostname: + try: + redirect_ips = socket.getaddrinfo( + parsed_redirect.hostname, parsed_redirect.port or 443 + ) + for _family, _stype, _proto, _cname, sockaddr in redirect_ips: + rip = ipaddress.ip_address(sockaddr[0]) + for blocked_cidr in settings.notification_blocked_ip_ranges: + try: + if rip in ipaddress.ip_network( + blocked_cidr, strict=False + ): + return ( + False, + f"Redirect to blocked IP range: {blocked_cidr}", + ) + except ValueError: + continue + except OSError: + return ( + False, + f"Could not resolve redirect target: {redirect_url}", + ) + + return True, None + except httpx.HTTPError as exc: + return False, str(exc) + except OSError as exc: + return False, str(exc) + + +def _send_smtp_email_sync( + target_email: str, + subject: str, + body_text: str, + body_html: str, +) -> None: + """Synchronously send an email using settings SMTP parameters.""" + from .config import settings + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = settings.smtp_from_email + msg["To"] = target_email + + msg.attach(MIMEText(body_text, "plain", "utf-8")) + msg.attach(MIMEText(body_html, "html", "utf-8")) + + with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10.0) as server: + if settings.smtp_use_tls: + server.starttls() + if settings.smtp_username and settings.smtp_password: + server.login(settings.smtp_username, settings.smtp_password) + server.sendmail(settings.smtp_from_email, [target_email], msg.as_string()) + + +async def send_email( + target_email: str, + payload: Dict[str, Any], +) -> tuple[bool, Optional[str]]: + """Send a rich SMTP email notification containing finding details asynchronously.""" + from .config import settings + + finding = payload.get("finding", {}) + finding_id = finding.get("id") + + if not settings.smtp_username or not settings.smtp_password: + logger.info( + "SMTP credentials not configured. Skipping email delivery (Logged placeholder): target=%s finding_id=%s", + target_email, + finding_id, + ) + return True, None + + subject = f"[SecuScan Alert] {finding.get('severity', 'INFO').upper()} vulnerability detected on {finding.get('target')}" + + body_text = ( + f"SecuScan Security Alert\n" + f"=======================\n\n" + f"A vulnerability has been identified during a scan run:\n\n" + f"Title: {finding.get('title')}\n" + f"Category: {finding.get('category')}\n" + f"Severity: {finding.get('severity')}\n" + f"Target: {finding.get('target')}\n\n" + f"Description:\n{finding.get('description')}\n\n" + f"Remediation Guidance:\n{finding.get('remediation')}\n\n" + f"View results in the SecuScan Dashboard." + ) + + title_esc = html.escape(str(finding.get('title') or "")) + category_esc = html.escape(str(finding.get('category') or "")) + severity_esc = html.escape(str(finding.get('severity') or "")) + target_esc = html.escape(str(finding.get('target') or "")) + description_esc = html.escape(str(finding.get('description') or "")).replace('\n', '<br>') + remediation_esc = html.escape(str(finding.get('remediation') or "")).replace('\n', '<br>') + + body_html = f"""<!DOCTYPE html> +<html> +<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #0f172a; max-width: 600px; margin: 0 auto; padding: 20px;"> + <h2 style="color: #991b1b; border-bottom: 2px solid #e2e8f0; padding-bottom: 10px;">🛡️ SecuScan Alert</h2> + <p>A new high-priority security vulnerability has been identified:</p> + <table style="border-collapse: collapse; width: 100%; margin: 20px 0;"> + <tr style="background-color: #f8fafc;"> + <td style="padding: 10px; border: 1px solid #e2e8f0; font-weight: bold; width: 140px;">Title</td> + <td style="padding: 10px; border: 1px solid #e2e8f0;">{title_esc}</td> + </tr> + <tr> + <td style="padding: 10px; border: 1px solid #e2e8f0; font-weight: bold;">Category</td> + <td style="padding: 10px; border: 1px solid #e2e8f0;">{category_esc}</td> + </tr> + <tr style="background-color: #f8fafc;"> + <td style="padding: 10px; border: 1px solid #e2e8f0; font-weight: bold;">Severity</td> + <td style="padding: 10px; border: 1px solid #e2e8f0; text-transform: uppercase; font-weight: bold; color: #991b1b;">{severity_esc}</td> + </tr> + <tr> + <td style="padding: 10px; border: 1px solid #e2e8f0; font-weight: bold;">Target</td> + <td style="padding: 10px; border: 1px solid #e2e8f0;">{target_esc}</td> + </tr> + </table> + <h3>Description</h3> + <p style="color: #475569; background-color: #f8fafc; padding: 15px; border-radius: 8px; border: 1px solid #e2e8f0;">{description_esc}</p> + <h3>Remediation Guidance</h3> + <p style="color: #166534; background-color: #f0fdf4; padding: 15px; border-radius: 8px; border: 1px solid #bbf7d0; border-left: 4px solid #22c55e;"> + {remediation_esc} + </p> + <p style="font-size: 11px; color: #64748b; margin-top: 40px; border-top: 1px solid #e2e8f0; padding-top: 15px;"> + This is an automated notification from your SecuScan installation. + </p> +</body> +</html>""" + + try: + await asyncio.to_thread(_send_smtp_email_sync, target_email, subject, body_text, body_html) + return True, None + except Exception as exc: + logger.error("Failed to send SMTP email notification to %s: %s", target_email, exc) + return False, str(exc) + + +async def deliver_via_rule( + db: Database, + rule: Dict[str, Any], + finding: Dict[str, Any], +) -> DeliveryResult: + """Attempt delivery for one rule/finding pair.""" + rule_id = str(rule["id"]) + finding_id = str(finding["id"]) + + if not bool(rule.get("is_active")): + return DeliveryResult( + rule_id=rule_id, + finding_id=finding_id, + status=NotificationDeliveryStatus.FAILED, + skipped=True, + error_message="Rule is inactive", + ) + + if not severity_meets_threshold( + str(finding.get("severity", "info")), + str(rule.get("severity_threshold", "info")), + ): + return DeliveryResult( + rule_id=rule_id, + finding_id=finding_id, + status=NotificationDeliveryStatus.FAILED, + skipped=True, + error_message="Finding severity below rule threshold", + ) + + if await was_already_delivered(db, rule_id, finding_id): + return DeliveryResult( + rule_id=rule_id, + finding_id=finding_id, + status=NotificationDeliveryStatus.SUCCESS, + skipped=True, + error_message="Already delivered", + ) + + payload = build_alert_payload(finding, rule) + channel = str(rule.get("channel_type", "")).lower() + target = str(rule.get("target_url_or_email", "")) + + if channel == NotificationChannelType.WEBHOOK.value: + ok, error = await send_webhook(target, payload) + elif channel == NotificationChannelType.EMAIL.value: + ok, error = await send_email(target, payload) + else: + ok, error = False, f"Unsupported channel type: {channel}" + + status = ( + NotificationDeliveryStatus.SUCCESS if ok else NotificationDeliveryStatus.FAILED + ) + await record_delivery(db, rule_id, finding_id, status, error) + + return DeliveryResult( + rule_id=rule_id, + finding_id=finding_id, + status=status, + error_message=error, + ) + + +async def process_finding_notifications( + db: Database, + finding_id: str, +) -> List[DeliveryResult]: + """Evaluate all active rules against one finding and attempt delivery.""" + finding = await db.fetchone("SELECT * FROM findings WHERE id = ?", (finding_id,)) + if not finding: + return [] + + rules = await db.fetchall( + "SELECT * FROM notification_rules WHERE is_active = 1 ORDER BY created_at ASC" + ) + results: List[DeliveryResult] = [] + for rule in rules: + results.append(await deliver_via_rule(db, rule, finding)) + return results + + +async def process_task_notifications( + db: Database, + task_id: str, +) -> List[DeliveryResult]: + """Evaluate notifications for every finding produced by a task.""" + findings = await db.fetchall( + "SELECT id FROM findings WHERE task_id = ? ORDER BY discovered_at ASC", + (task_id,), + ) + results: List[DeliveryResult] = [] + for row in findings: + results.extend(await process_finding_notifications(db, str(row["id"]))) + return results diff --git a/backend/secuscan/platform_resources.py b/backend/secuscan/platform_resources.py new file mode 100644 index 000000000..8b84ac00f --- /dev/null +++ b/backend/secuscan/platform_resources.py @@ -0,0 +1,197 @@ +"""Helpers for target policies, profiles, crawl runs, and asset persistence.""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from datetime import datetime +from typing import Any, Dict, Iterable, List, Optional + +from .database import Database +from .execution_context import normalize_execution_context +from .models import ExecutionContext + + +def _now_iso() -> str: + return datetime.utcnow().isoformat() + + +def _stable_asset_id(target: str, host: Any, port: Any, protocol: Any) -> str: + material = "||".join( + [ + str(target or "").strip().lower(), + str(host or "").strip().lower(), + str(port or "").strip().lower(), + str(protocol or "").strip().lower(), + ] + ) + digest = hashlib.sha1(material.encode("utf-8")).hexdigest()[:16] + return f"asset:{digest}" + + +async def get_target_policy(db: Database, owner_id: str, policy_id: str | None) -> Optional[Dict[str, Any]]: + if not policy_id: + return None + row = await db.fetchone( + "SELECT * FROM target_policies WHERE id = ? AND owner_id = ?", + (policy_id, owner_id), + ) + return _deserialize_resource_row(row) + + +async def get_credential_profile(db: Database, owner_id: str, profile_id: str | None) -> Optional[Dict[str, Any]]: + if not profile_id: + return None + row = await db.fetchone( + "SELECT * FROM credential_profiles WHERE id = ? AND owner_id = ?", + (profile_id, owner_id), + ) + return _deserialize_resource_row(row) + + +async def get_session_profile(db: Database, owner_id: str, profile_id: str | None) -> Optional[Dict[str, Any]]: + if not profile_id: + return None + row = await db.fetchone( + "SELECT * FROM session_profiles WHERE id = ? AND owner_id = ?", + (profile_id, owner_id), + ) + return _deserialize_resource_row(row) + + +async def persist_crawl_run( + db: Database, + *, + owner_id: str, + task_id: str, + plugin_id: str, + target: str, + crawl: Dict[str, Any], + status: str = "completed", +) -> str: + crawl_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO crawl_runs ( + id, owner_id, task_id, plugin_id, target, seed_url, status, + summary_json, pages_json, forms_json, scripts_json, params_json, api_hints_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + crawl_id, + owner_id, + task_id, + plugin_id, + target, + crawl.get("seed_url") or target, + status, + json.dumps( + { + "final_url": crawl.get("final_url"), + "status_code": crawl.get("status_code"), + "page_count": len(crawl.get("pages", [])), + "form_count": len(crawl.get("forms", [])), + "api_hint_count": len(crawl.get("api_hints", [])), + } + ), + json.dumps(crawl.get("pages", [])), + json.dumps(crawl.get("forms", [])), + json.dumps(crawl.get("scripts", [])), + json.dumps(crawl.get("params", [])), + json.dumps(crawl.get("api_hints", [])), + ), + ) + return crawl_id + + +async def replace_asset_services( + db: Database, + *, + owner_id: str, + task_id: str, + plugin_id: str, + target: str, + services: Iterable[Dict[str, Any]], +) -> None: + await db.execute("DELETE FROM asset_services WHERE task_id = ?", (task_id,)) + for item in services: + metadata = item.get("metadata", {}) if isinstance(item.get("metadata"), dict) else {} + host = str(item.get("host") or target) + port = item.get("port") + protocol = item.get("protocol") + asset_id = str(item.get("asset_id") or _stable_asset_id(target, host, port, protocol)) + cert_sans = item.get("cert_san") or item.get("cert_sans") or metadata.get("cert_san") or metadata.get("cert_sans") or [] + if not isinstance(cert_sans, list): + cert_sans = [cert_sans] + service_fingerprint = item.get("service_fingerprint") + if not service_fingerprint: + service_fingerprint = " ".join( + str(part).strip() + for part in ( + item.get("product"), + item.get("version"), + item.get("service"), + item.get("title"), + ) + if str(part or "").strip() + ) or None + await db.execute( + """ + INSERT INTO asset_services ( + id, owner_id, task_id, plugin_id, target, asset_id, host, ip, port, protocol, + service, product, version, cpe, confidence, title, banner, cert_subject, + cert_san_json, cert_expiry, service_fingerprint, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + str(uuid.uuid4()), + owner_id, + task_id, + plugin_id, + target, + asset_id, + host, + item.get("ip"), + item.get("port"), + item.get("protocol"), + item.get("service"), + item.get("product"), + item.get("version"), + item.get("cpe"), + item.get("confidence"), + item.get("title"), + item.get("banner"), + item.get("cert_subject"), + json.dumps(cert_sans), + item.get("cert_expiry"), + service_fingerprint, + json.dumps(metadata), + ), + ) + + +def serialize_execution_context(context: ExecutionContext | Dict[str, Any] | None) -> str: + return json.dumps(normalize_execution_context(context or {})) + + +def _deserialize_resource_row(row: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: + if row is None: + return None + item = dict(row) + for key in list(item.keys()): + if key.endswith("_json") and isinstance(item[key], str): + try: + item[key[:-5]] = json.loads(item[key]) + except json.JSONDecodeError: + item[key[:-5]] = item[key] + return item + + +def deserialize_resource_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + results: List[Dict[str, Any]] = [] + for row in rows: + parsed = _deserialize_resource_row(row) + if parsed is not None: + results.append(parsed) + return results diff --git a/backend/secuscan/plugin_validator.py b/backend/secuscan/plugin_validator.py index 7c3ac455f..ad6aefa7e 100644 --- a/backend/secuscan/plugin_validator.py +++ b/backend/secuscan/plugin_validator.py @@ -23,6 +23,12 @@ VALID_FIELD_TYPES = {"string","integer","text", "number", "boolean", "select", "multiselect", "textarea"} VALID_PARSER_TYPES = {"json", "text", "custom", "none"} +VALID_CATEGORIES = { + "recon", "vulnerability", "web", "exploit", "network", + "expert", "code", "forensics", "utils", "execution", + "security", "robots", +} + REQUIRED_TOP_LEVEL_FIELDS = [ "id", "name", @@ -58,6 +64,7 @@ class ValidationResult: plugin_id: str plugin_dir: Path errors: list = field(default_factory=list) + warnings: list = field(default_factory=list) @property def valid(self) -> bool: @@ -66,6 +73,9 @@ def valid(self) -> bool: def add(self, path: str, message: str) -> None: self.errors.append(ValidationError(self.plugin_id, path, message)) + def add_warning(self, path: str, message: str) -> None: + self.warnings.append(ValidationError(self.plugin_id, path, message)) + # --------------------------------------------------------------------------- # Core validator @@ -109,6 +119,7 @@ def validate(self) -> ValidationResult: result = ValidationResult(plugin_id=plugin_id, plugin_dir=self.plugin_dir) self._check_required_fields(data, result) + self._check_category(data, result) self._check_engine(data, result) self._check_command_template(data, result) self._check_fields(data, result) @@ -126,6 +137,17 @@ def _check_required_fields(self, data: dict, result: ValidationResult) -> None: if key not in data or data[key] in (None, "", [], {}): result.add(key, f"Required field '{key}' is missing or empty") + def _check_category(self, data: dict, result: ValidationResult) -> None: + cat = data.get("category") + if not cat: + return + if cat not in VALID_CATEGORIES: + result.add( + "category", + f"'{cat}' is not a recognized category — " + f"must be one of: {sorted(VALID_CATEGORIES)}", + ) + def _check_engine(self, data: dict, result: ValidationResult) -> None: engine = data.get("engine") if not isinstance(engine, dict): @@ -214,6 +236,12 @@ def _check_fields(self, data: dict, result: ValidationResult) -> None: f"Field '{fid}' is type '{ftype}' and must have a non-empty 'options' list", ) + if not f.get("help"): + result.add_warning( + f"{prefix}.help", + f"Field '{fid}' is missing 'help' text — add a brief user-facing description", + ) + def _check_output(self, data: dict, result: ValidationResult) -> None: output = data.get("output") if not isinstance(output, dict): diff --git a/backend/secuscan/plugins.py b/backend/secuscan/plugins.py index 8e158ccbb..623c3e4d1 100644 --- a/backend/secuscan/plugins.py +++ b/backend/secuscan/plugins.py @@ -15,6 +15,7 @@ from .models import PluginMetadata, PluginFieldType from .config import settings from .capabilities import validate_capability_list, ALL_CAPABILITIES +from .validation import sanitize_input # Port specifications: one or more comma-separated port numbers or port ranges. # Valid: "22", "80,443", "1-1000", "22,80,1000-2000" @@ -33,6 +34,54 @@ logger = logging.getLogger(__name__) +_PLACEHOLDER_PLUGIN_IDS = frozenset({ + "zap_scanner", + "sniper", +}) + +_NATIVE_PLUGIN_IDS = frozenset({ + "network_scanner", + "api_scanner", + "xss_exploiter", + "web_scanner", + "recon_scanner", + "port_scanner", +}) + +_VALIDATION_PRESETS: Dict[str, Dict[str, Any]] = { + "url": { + "pattern": re.compile(r"^https?://[^\s/$.?#].[^\s]*$", re.IGNORECASE), + "message": "Must be a valid URL starting with http:// or https://", + }, + "hostname": { + "pattern": re.compile( + r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$" + ), + "message": "Must be a valid hostname (e.g. example.com or sub.example.com)", + }, + "domain": { + "pattern": re.compile(r"^(?!https?://)(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$"), + "message": "Must be a valid domain name without a scheme (e.g. example.com)", + }, + "ipv4": { + "pattern": re.compile( + r"^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)$" + ), + "message": "Must be a valid IPv4 address (e.g. 192.168.1.1)", + }, + "port": { + "pattern": re.compile( + r"^(6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|[1-9]\d{0,3}|[1-9])$" + ), + "message": "Must be a valid port number between 1 and 65535", + }, + "cidr": { + "pattern": re.compile( + r"^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)){3}/(3[0-2]|[12]\d|[0-9])$" + ), + "message": "Must be a valid CIDR block (e.g. 192.168.1.0/24)", + }, +} def _is_absolute_path(value: str) -> bool: """Check if a path is absolute regardless of the server OS. @@ -46,7 +95,6 @@ def _is_absolute_path(value: str) -> bool: return True return bool(re.match(r'^[a-zA-Z]:[/\\]', value)) - class PluginManager: """Manages plugin loading and validation""" @@ -281,6 +329,9 @@ def list_plugins(self) -> List[Dict]: "requires_consent": bool(plugin.safety.get("requires_consent", False)), "consent_message": plugin.safety.get("consent_message"), "capabilities": plugin.capabilities or [], + "implementation_status": self._resolve_implementation_status(plugin), + "supports_authenticated_crawling": bool(getattr(plugin, "supports_authenticated_crawling", False)), + "supports_session_reuse": bool(getattr(plugin, "supports_session_reuse", False)), "availability": { "runnable": len(missing_binaries) == 0, "missing_binaries": missing_binaries, @@ -325,11 +376,25 @@ def get_plugin_schema(self, plugin_id: str) -> Optional[Dict]: "description": plugin.description, "fields": [f.model_dump() for f in plugin.fields], "presets": plugin.presets, - "safety": plugin.safety + "safety": plugin.safety, + "implementation_status": self._resolve_implementation_status(plugin), + "supports_authenticated_crawling": bool(getattr(plugin, "supports_authenticated_crawling", False)), + "supports_session_reuse": bool(getattr(plugin, "supports_session_reuse", False)), } else: return None + def _resolve_implementation_status(self, plugin: PluginMetadata) -> str: + """Resolve implementation maturity without requiring every plugin to be edited.""" + explicit = getattr(plugin, "implementation_status", None) + if explicit: + return str(explicit) + if plugin.id in _PLACEHOLDER_PLUGIN_IDS: + return "placeholder" + if plugin.id in _NATIVE_PLUGIN_IDS: + return "native" + return "integrated" + def _interpolate(self, token: str, inputs: Dict) -> Optional[str]: """Interpolate variables in a token string.""" if "{" not in token or "}" not in token: @@ -347,7 +412,7 @@ def _interpolate(self, token: str, inputs: Dict) -> Optional[str]: return None placeholder = "{" + var_name + (f":{default_value}" if default_value else "") + "}" - rendered = rendered.replace(placeholder, str(value)) + rendered = rendered.replace(placeholder, sanitize_input(str(value))) return rendered @@ -485,7 +550,7 @@ def _validate_inputs_against_schema( for field_id, raw_value in inputs.items(): # Strip internal control fields — they are not part of the plugin schema - if field_id in _INTERNAL_CONTROL_FIELDS: + if field_id in _INTERNAL_CONTROL_FIELDS or field_id.startswith("__"): continue field = field_map.get(field_id) @@ -528,12 +593,19 @@ def _validate_inputs_against_schema( if field.type in (PluginFieldType.STRING, PluginFieldType.TEXT): value_str = str(raw_value) - # Pattern validation from field metadata + # Pattern / validation_type validation from field metadata validation = field.validation or {} - pattern = validation.get("pattern") - if pattern and not re.match(pattern, value_str): - msg = validation.get("message", f"Value does not match pattern {pattern!r}") - raise ValueError(f"Field '{field_id}': {msg}") + validation_type = validation.get("validation_type") + if validation_type and validation_type in _VALIDATION_PRESETS: + preset = _VALIDATION_PRESETS[validation_type] + if not preset["pattern"].match(value_str): + msg = validation.get("message", preset["message"]) + raise ValueError(f"Field '{field_id}': {msg}") + else: + pattern = validation.get("pattern") + if pattern and not re.match(pattern, value_str): + msg = validation.get("message", f"Value does not match pattern {pattern!r}") + raise ValueError(f"Field '{field_id}': {msg}") # Reject argv-level flag injection self._reject_injected_args(field_id, value_str) @@ -553,6 +625,12 @@ def build_command(self, plugin_id: str, inputs: Dict) -> Optional[List[str]]: if not plugin: return None + inputs = { + key: value + for key, value in inputs.items() + if key not in _INTERNAL_CONTROL_FIELDS and not str(key).startswith("__") + } + # Validate before normalisation so SELECT checks run against raw user values self._validate_inputs_against_schema(plugin, inputs) inputs = self._normalize_inputs(plugin, inputs) @@ -593,11 +671,9 @@ def build_command(self, plugin_id: str, inputs: Dict) -> Optional[List[str]]: return command - # Global plugin manager instance plugin_manager: Optional[PluginManager] = None - async def init_plugins(plugins_dir: str) -> PluginManager: """Initialize plugin manager and load plugins""" global plugin_manager @@ -605,7 +681,6 @@ async def init_plugins(plugins_dir: str) -> PluginManager: await plugin_manager.load_plugins() return plugin_manager - def get_plugin_manager() -> PluginManager: """Get plugin manager instance""" if plugin_manager is None: diff --git a/backend/secuscan/ratelimit.py b/backend/secuscan/ratelimit.py index ed3df69fd..8cf4c5e75 100644 --- a/backend/secuscan/ratelimit.py +++ b/backend/secuscan/ratelimit.py @@ -237,9 +237,28 @@ async def reset(self): self.last_cleanup = None +class WorkflowRateLimiter: + """Rate limiter for scheduler-triggered workflow scans.""" + + def __init__(self): + self._last_run: Dict[str, datetime] = {} + self.lock = asyncio.Lock() + + async def check_workflow_rate_limit(self, workflow_id: str, min_interval_seconds: int) -> Tuple[bool, str]: + async with self.lock: + now = datetime.now() + last = self._last_run.get(workflow_id) + if last and (now - last).total_seconds() < min_interval_seconds: + remaining = min_interval_seconds - (now - last).total_seconds() + return False, f"Workflow rate limited: wait {remaining:.0f}s between runs" + self._last_run[workflow_id] = now + return True, "" + + # Global instances rate_limiter = RateLimiter() concurrent_limiter = ConcurrentTaskLimiter() +workflow_rate_limiter = WorkflowRateLimiter() # Route-specific limiters task_start_limiter = EndpointRateLimiter( @@ -266,6 +285,17 @@ async def reset(self): window_seconds=settings.rate_limit_read_heavy_window ) +admin_limiter = EndpointRateLimiter( + bucket_name="admin", + limit=30, + window_seconds=60 +) + +scheduler_tick_limiter = EndpointRateLimiter( + bucket_name="scheduler_tick", + limit=settings.rate_limit_scheduler_tick_limit, + window_seconds=settings.rate_limit_scheduler_tick_window +) async def reset_all_endpoint_limiters(): """Reset rate limiting history for all route-specific buckets.""" @@ -273,3 +303,5 @@ async def reset_all_endpoint_limiters(): await vault_limiter.reset() await report_download_limiter.reset() await read_heavy_limiter.reset() + await admin_limiter.reset() + await scheduler_tick_limiter.reset() diff --git a/backend/secuscan/reporting.py b/backend/secuscan/reporting.py index fb2e8987c..f71658f09 100644 --- a/backend/secuscan/reporting.py +++ b/backend/secuscan/reporting.py @@ -5,6 +5,7 @@ import json import re from .redaction import redact, redact_dict +from .ai_summary import generate_summary from datetime import datetime from functools import lru_cache from typing import Any, Dict, List @@ -28,6 +29,88 @@ class ReportGenerator: "INFO": (71, 85, 105), } + @classmethod + def _generate_severity_chart(cls, severity_counts: Dict[str, int]) -> str: + """Generate a base64 PNG horizontal bar chart representing the vulnerability distribution.""" + width, height = 480, 160 + img = Image.new("RGBA", (width, height), (255, 255, 255, 0)) + draw = ImageDraw.Draw(img) + + colors_map = { + "CRITICAL": (153, 27, 27, 255), + "HIGH": (220, 38, 38, 255), + "MEDIUM": (217, 119, 6, 255), + "LOW": (37, 99, 235, 255), + "INFO": (71, 85, 105, 255) + } + + severities = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"] + max_val = max(severity_counts.values()) if any(severity_counts.values()) else 1 + + # Draw background container + draw.rounded_rectangle([0, 0, width - 1, height - 1], radius=8, fill=(248, 250, 252, 255), outline=(226, 232, 240, 255), width=1) + + y_offset = 12 + bar_height = 16 + spacing = 10 + x_start = 110 + max_bar_width = 280 + + from PIL import ImageFont + font = None + for font_name in ("arial.ttf", "Helvetica.ttf", "segoeui.ttf", "sans-serif.ttf"): + try: + font = ImageFont.truetype(font_name, 12) + break + except Exception: + continue + if font is None: + try: + font = ImageFont.load_default() + except Exception: + font = None + + for i, sev in enumerate(severities): + count = severity_counts.get(sev, 0) + bar_len = int((count / max_val) * max_bar_width) if count > 0 else 0 + color = colors_map[sev] + + # Draw background progress track + draw.rounded_rectangle([x_start, y_offset, x_start + max_bar_width, y_offset + bar_height], radius=4, fill=(226, 232, 240, 255)) + + # Draw actual severity bar + if count > 0: + draw.rounded_rectangle([x_start, y_offset, x_start + bar_len, y_offset + bar_height], radius=4, fill=color) + + # Draw labels + if font: + # Severity label + draw.text((20, y_offset + 2), sev.title(), fill=(71, 85, 105, 255), font=font) + # Count label + draw.text((x_start + bar_len + 10, y_offset + 2), str(count), fill=(15, 23, 42, 255), font=font) + + y_offset += bar_height + spacing + + output = io.BytesIO() + img.save(output, format="PNG") + encoded = base64.b64encode(output.getvalue()).decode("ascii") + return f"data:image/png;base64,{encoded}" + + @classmethod + def _get_ai_summary(cls, findings): + """Return an AI executive summary, or '' when the feature is disabled.""" + from .config import settings as _settings + if not _settings.ai_summary_enabled: + return "" + if not _settings.ai_summary_api_key: + return "" + return generate_summary( + findings=findings, + model=_settings.ai_summary_model, + api_key=_settings.ai_summary_api_key, + base_url=_settings.ai_summary_base_url or None, + ) + @staticmethod def _hex_to_rgb(value: str) -> tuple[int, int, int]: value = value.strip("#") @@ -119,7 +202,15 @@ def _normalize_finding(cls, finding: Any) -> Dict[str, Any]: "cve": cls._clean_text(finding.get("cve")), "cwe": cls._clean_text(finding.get("cwe")), "cvss": finding.get("cvss"), + "validated": bool(finding.get("validated", False)), + "validation_method": cls._clean_text(finding.get("validation_method")), + "confidence_reason": redact(cls._clean_text(finding.get("confidence_reason"))), + "service_fingerprint": cls._clean_text(finding.get("service_fingerprint")), + "cpe": cls._clean_text(finding.get("cpe")), "discovered_at": cls._clean_text(finding.get("discovered_at")), + "evidence": finding.get("evidence", []) if isinstance(finding.get("evidence"), list) else [], + "asset_refs": finding.get("asset_refs", []) if isinstance(finding.get("asset_refs"), list) else [], + "references": finding.get("references", []) if isinstance(finding.get("references"), list) else [], "metadata": redact_dict({cls._clean_text(key): cls._clean_text(val) for key, val in metadata.items()}), } if normalized["severity"] not in cls.SEVERITY_COLORS: @@ -171,6 +262,20 @@ def _build_scan_parameters(cls, task: Dict[str, Any]) -> List[Dict[str, str]]: if preset: parameters.append({"label": "Preset", "value": preset}) + execution_context = task.get("execution_context") + if not execution_context: + raw_context = task.get("execution_context_json") + if isinstance(raw_context, str): + try: + execution_context = json.loads(raw_context) + except json.JSONDecodeError: + execution_context = {} + if isinstance(execution_context, dict): + for key in ("target_policy_id", "scan_profile", "credential_profile_id", "session_profile_id", "validation_mode", "evidence_level"): + value = cls._clean_text(execution_context.get(key)) + if value: + parameters.append({"label": key.replace("_", " ").title(), "value": value}) + for key, value in cls._normalize_task_inputs(task).items(): label = key.replace("_", " ").title() formatted = cls._format_input_value(value) @@ -289,6 +394,7 @@ def _generate_pdf_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) payload = cls._build_report_payload(task, result) findings = payload["findings"] severity_counts = payload["severity_counts"] + ai_summary = cls._get_ai_summary(findings) shield_icon = cls._icon_data_uri("shield", "1e3a5f") target_icon = cls._icon_data_uri("target", "2563eb") findings_icon = cls._icon_data_uri("findings", "0f172a") @@ -319,6 +425,10 @@ def _generate_pdf_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) <h4>Description</h4> <p>{cls._escape_html(finding['description'])}</p> {f"<h4>Evidence</h4><pre>{cls._escape_html(finding['proof'])}</pre>" if finding['proof'] else ""} + {f"<p class='meta'>Validated: {'YES' if finding['validated'] else 'NO'}</p>" if finding['validated'] or finding['validation_method'] else ""} + {f"<p class='meta'>Validation method: {cls._escape_html(finding['validation_method'])}</p>" if finding['validation_method'] else ""} + {f"<p class='meta'>CPE: {cls._escape_html(finding['cpe'])}</p>" if finding['cpe'] else ""} + {f"<p class='meta'>Evidence items: {len(finding['evidence'])}</p>" if finding['evidence'] else ""} {f"<div class='remediation'><h4>Recommended action</h4><p>{cls._escape_html(finding['remediation'])}</p></div>" if finding['remediation'] else ""} {f"<p class='meta'>CVE: {cls._escape_html(finding['cve'])}</p>" if finding['cve'] else ""} </div> @@ -554,6 +664,7 @@ def _generate_pdf_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) </table> <h2><img class="stat-icon" src="{shield_icon}" alt=""> Executive Overview</h2> + {f'''<div style="margin-bottom:12px;padding:10px 12px;background:#f0f4ff;border-left:4px solid #2563eb;border-radius:3px;"><strong style="display:block;margin-bottom:4px;font-size:9px;text-transform:uppercase;letter-spacing:.08em;color:#1e3a8a;">🤖 AI Executive Summary</strong><p style="margin:0;font-size:9px;line-height:1.55;color:#1e293b;">{cls._escape_html(ai_summary)}</p></div>''' if ai_summary else ""} <ul>{summary_markup}</ul> <h2><img class="stat-icon" src="{clock_icon}" alt=""> Assessment Details</h2> @@ -595,6 +706,7 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s payload = cls._build_report_payload(task, result) findings = payload["findings"] severity_counts = payload["severity_counts"] + ai_summary = cls._get_ai_summary(findings) shield_icon = cls._icon_data_uri("shield", "1e3a5f") target_icon = cls._icon_data_uri("target", "2563eb") findings_icon = cls._icon_data_uri("findings", "0f172a") @@ -602,6 +714,7 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s rows_icon = cls._icon_data_uri("rows", "2563eb") clock_icon = cls._icon_data_uri("clock", "475569") target_html = cls._escape_html_with_breaks(payload["target"]) + severity_chart_data = cls._generate_severity_chart(severity_counts) summary_markup = "".join( f"<li>{cls._escape_html(line)}</li>" for line in payload["summary"] @@ -612,7 +725,7 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s ) finding_markup = "".join( f""" - <article class="finding-card"> + <article class="finding-card severity-{finding['severity'].lower()}"> <div class="finding-top"> <span class="severity severity-{finding['severity'].lower()}"><img class="mini-icon" src="{critical_icon}" alt=""> {cls._escape_html(finding['severity'])}</span> <div class="finding-heading"> @@ -626,6 +739,10 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s <p>{cls._escape_html(finding['description'])}</p> </section> {f"<section><h4>Evidence</h4><pre>{cls._escape_html(finding['proof'])}</pre></section>" if finding['proof'] else ""} + {f"<section class='meta'><span>Validated: {'YES' if finding['validated'] else 'NO'}</span></section>" if finding['validated'] or finding['validation_method'] else ""} + {f"<section class='meta'><span>Validation method: {cls._escape_html(finding['validation_method'])}</span></section>" if finding['validation_method'] else ""} + {f"<section class='meta'><span>CPE: {cls._escape_html(finding['cpe'])}</span></section>" if finding['cpe'] else ""} + {f"<section class='meta'><span>Evidence items: {len(finding['evidence'])}</span></section>" if finding['evidence'] else ""} {f"<section class='remediation'><h4>Recommended action</h4><p>{cls._escape_html(finding['remediation'])}</p></section>" if finding['remediation'] else ""} {f"<section class='meta'><span>CVE: {cls._escape_html(finding['cve'])}</span></section>" if finding['cve'] else ""} </div> @@ -797,6 +914,17 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s }} .finding-card {{ overflow: hidden; + border-left: 6px solid var(--info); + }} + .finding-card.severity-critical {{ border-left-color: var(--critical); }} + .finding-card.severity-high {{ border-left-color: var(--high); }} + .finding-card.severity-medium {{ border-left-color: var(--medium); }} + .finding-card.severity-low {{ border-left-color: var(--low); }} + .finding-card.severity-info {{ border-left-color: var(--info); }} + + .finding-card:hover {{ + transform: translateY(-2px); + box-shadow: 0 16px 36px rgba(15, 23, 42, 0.08); }} .finding-top {{ display: flex; @@ -922,8 +1050,16 @@ def generate_html_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> s <section class="section"> <h2><img class="section-icon" src="{shield_icon}" alt="">Executive Overview</h2> - <p class="section-copy">Key takeaways generated from the parsed assessment data.</p> - <ul class="summary-list">{summary_markup}</ul> + <p class="section-copy">Key takeaways and severity distribution generated from the parsed assessment data.</p> + <div class="executive-container" style="display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap;"> + <div style="flex: 1; min-width: 300px;"> + {f'''<div style="margin:0 0 18px;padding:16px 20px;background:#eff6ff;border-left:4px solid #2563eb;border-radius:14px;"><p style="margin:0 0 6px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.1em;color:#1d4ed8;">🤖 AI Executive Summary</p><p style="margin:0;color:#1e293b;line-height:1.65;">{cls._escape_html(ai_summary)}</p></div>''' if ai_summary else ""} + <ul class="summary-list">{summary_markup}</ul> + </div> + <div class="chart-container" style="flex: 0 0 400px; max-width: 100%;"> + <img src="{severity_chart_data}" alt="Severity Distribution Chart" style="width: 100%; height: auto; border-radius: 12px; box-shadow: 0 10px 25px rgba(0,0,0,0.05);" /> + </div> + </div> </section> <section class="section"> @@ -955,6 +1091,10 @@ def generate_csv_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> st "Target", "CVSS", "CVE", + "CPE", + "Validated", + "Validation Method", + "Confidence Reason", "Description", "Evidence", "Remediation", @@ -969,6 +1109,10 @@ def generate_csv_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> st finding["target"] or payload["target"], finding["cvss"] if finding["cvss"] is not None else "", finding["cve"], + finding["cpe"], + "yes" if finding["validated"] else "no", + finding["validation_method"], + finding["confidence_reason"], finding["description"], finding["proof"], finding["remediation"], @@ -1043,7 +1187,10 @@ def generate_sarif_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> "text": finding.get("remediation", "No remediation provided.") }, "properties": { - "precision": "high" + "precision": "high", + "cpe": finding.get("cpe"), + "validated": finding.get("validated"), + "validation_method": finding.get("validation_method"), } }) @@ -1054,7 +1201,13 @@ def generate_sarif_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> "text": finding.get("description", "Security finding detected") }, "level": severity_map.get(finding["severity"], "note"), - "locations": [] + "locations": [], + "properties": { + "confidenceReason": finding.get("confidence_reason"), + "cpe": finding.get("cpe"), + "validated": finding.get("validated"), + "assetRefs": finding.get("asset_refs", []), + }, } # Attempt to extract location if available diff --git a/backend/secuscan/request_context.py b/backend/secuscan/request_context.py index e4fa71d8e..ab1419b6c 100644 --- a/backend/secuscan/request_context.py +++ b/backend/secuscan/request_context.py @@ -1,4 +1,5 @@ from contextvars import ContextVar +from typing import Optional from uuid import uuid4 request_id_context: ContextVar[str] = ContextVar( @@ -9,7 +10,7 @@ def get_request_id() -> str: return request_id_context.get() -def set_request_id(request_id: str = None) -> str: +def set_request_id(request_id: Optional[str] = None) -> str: request_id = request_id or str(uuid4()) request_id_context.set(request_id) return request_id \ No newline at end of file diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index 1fba3ee2c..44d1103d6 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -9,36 +9,50 @@ import logging import re import os -import shutil import uuid import asyncio from pathlib import Path -from urllib.parse import urlparse - -def parse_json_fields(rows: List[Dict], fields: List[str]) -> List[Dict]: - """Helper to parse stringified JSON fields from SQLite.""" - parsed = [] - for row in rows: - item = dict(row) - for field in fields: - if item.get(field) and isinstance(item[field], str): - try: - item[field] = json.loads(item[field]) - except json.JSONDecodeError: - pass - parsed.append(item) - return parsed +from urllib.parse import urlencode, urlparse + +from .routes_json_helpers import ( + FINDING_JSON_FIELDS, + deserialize_asset_service_rows, + deserialize_finding_rows, + parse_json_fields, +) + +__all__ = [ + "FINDING_JSON_FIELDS", + "parse_json_fields", + "deserialize_finding_rows", + "deserialize_asset_service_rows", +] def _parse_workflow_steps(raw_steps: Any) -> List[Dict[str, Any]]: if isinstance(raw_steps, list): - return raw_steps - if not raw_steps: - return [] - try: - parsed = json.loads(raw_steps) - except (TypeError, json.JSONDecodeError): - return [] - return parsed if isinstance(parsed, list) else [] + parsed = raw_steps + elif not raw_steps: + parsed = [] + else: + try: + parsed = json.loads(raw_steps) + except (TypeError, json.JSONDecodeError): + parsed = [] + normalized: List[Dict[str, Any]] = [] + for step in parsed if isinstance(parsed, list) else []: + if not isinstance(step, dict): + continue + try: + model = WorkflowStep( + plugin_id=str(step.get("plugin_id", "")), + inputs=step.get("inputs") or {}, + preset=step.get("preset"), + execution_context=step.get("execution_context") or {}, + ) + except Exception: + continue + normalized.append(model.model_dump()) + return normalized def _serialize_workflow(row: Dict[str, Any], queued_task_ids: Optional[List[str]] = None) -> Dict[str, Any]: """Return the workflow shape consumed by the frontend.""" @@ -54,35 +68,11 @@ def _serialize_workflow(row: Dict[str, Any], queued_task_ids: Optional[List[str] } -def is_filesystem_target(target: str) -> bool: - """Best-effort detection for path-based targets that should bypass host validation.""" - # Absolute or relative filesystem roots only — not CIDR notation (e.g. 8.8.8.8/32) - if target.startswith(("/", "./", "../", "~/")): - return True - # Windows drive paths (C:\ or C:/) - """ - Return True only for genuine local filesystem paths. - - Explicit roots accepted: - - Unix absolute paths: /home/user/repo - - Unix relative paths: ./src, ../lib - - Home-relative paths: ~/projects - - Windows paths: C:\\Users\\repo, D:/work +def _json_payload(value: Any, fallback: str) -> str: + return json.dumps(value if value is not None else json.loads(fallback)) - Anything else — including CIDR notation (8.8.8.8/32, 192.168.1.0/24), - bare hostnames, URLs, and domain paths — returns False and will be - subject to the full validate_target() check including safe-mode enforcement. - CIDR notation is handled correctly by ipaddress.ip_network() inside - validate_target() and does NOT need special-casing here. - """ - # Unix absolute, relative, and home-relative paths - if target.startswith(("/", "./", "../", "~/")): - return True - # Windows paths: C:\ or C:/ - if re.match(r"^[A-Za-z]:[\\/]", target): - return True - return False +from .validation import is_filesystem_target # noqa: E402 def _slugify_filename_part(value: str, fallback: str) -> str: cleaned = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") @@ -109,7 +99,8 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str: TaskCreateRequest, TaskResponse, TaskResult, PluginListResponse, ErrorResponse, BulkDeleteRequest, NotificationRuleCreate, NotificationRuleUpdate, - NotificationChannelType, + NotificationChannelType, TaskStatus, + ExecutionContext, WorkflowStep, ValidationMode, EvidenceLevel, ) from .config import settings from .database import get_db @@ -117,16 +108,26 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str: from .executor import executor from .redaction import redact_inputs from .ratelimit import ( - rate_limiter, concurrent_limiter, + rate_limiter, concurrent_limiter, workflow_rate_limiter, task_start_limiter, vault_limiter, report_download_limiter, read_heavy_limiter, - resolve_client_identity, + resolve_client_identity, admin_limiter, + scheduler_tick_limiter, ) from .validation import validate_target, validate_task_start_payload, validate_url from .reporting import reporting from .vault import VaultCrypto from .workflows import scheduler -from .auth import require_api_key +from .auth import require_api_key, get_current_owner +from .execution_context import is_offensive_validation, normalize_execution_context +from .finding_intelligence import build_asset_summary, build_finding_groups +from .knowledgebase import KnowledgeBase +from .platform_resources import ( + deserialize_resource_rows, + get_credential_profile, + get_session_profile, + get_target_policy, +) from sse_starlette.sse import EventSourceResponse @@ -145,6 +146,22 @@ def _validate_notification_target(channel_type: NotificationChannelType, target: is_valid, error = validate_url(cleaned) if not is_valid: raise HTTPException(status_code=400, detail=error or "Invalid webhook URL") + + if settings.notification_ssrf_enabled: + from .validation import resolve_and_validate_target, validate_webhook_target + ssrf_ok, ssrf_err = resolve_and_validate_target(cleaned) + if not ssrf_ok: + raise HTTPException( + status_code=400, + detail=f"Webhook target blocked by SSRF protection: {ssrf_err}" + ) + # Additional independent check against notification_blocked_ip_ranges + target_ok, target_err = validate_webhook_target(cleaned) + if not target_ok: + raise HTTPException( + status_code=400, + detail=f"Webhook target blocked by SSRF protection: {target_err}" + ) return cleaned if not _EMAIL_PATTERN.match(cleaned): @@ -194,6 +211,21 @@ async def invalidate_view_cache(): await cache.delete_prefix(prefix) +async def require_owned_task(db, task_id: str, owner: str, columns: str = "owner_id") -> Dict[str, Any]: + """Fetch a task and enforce that it belongs to ``owner`` (issue #401). + + Returns the selected row on success. Raises 404 when the task does not + exist and 403 when it is owned by a different user/workspace. ``columns`` + must include ``owner_id`` so the ownership comparison can be made. + """ + row = await db.fetchone(f"SELECT {columns} FROM tasks WHERE id = ?", (task_id,)) + if row is None: + raise HTTPException(status_code=404, detail="Task not found") + if row.get("owner_id") != owner: + raise HTTPException(status_code=403, detail="You do not have access to this task") + return row + + def iter_raw_output_chunks(path: str, chunk_size: int = SSE_RAW_OUTPUT_CHUNK_SIZE): """Yield raw output in bounded chunks for completed-task SSE replay.""" with open(path, "r", encoding="utf-8", errors="replace") as output_file: @@ -298,13 +330,15 @@ async def start_task( request: TaskCreateRequest, background_tasks: BackgroundTasks, raw_request: Request, + owner: str = Depends(get_current_owner), ): """ Start a new scan task. """ # ── Payload size / field-length guard ───────────────────────────────── raw_body = await raw_request.body() - ok, status_code, error_msg = validate_task_start_payload(raw_body, request.inputs) + execution_context = normalize_execution_context(request.execution_context) + ok, status_code, error_msg = validate_task_start_payload(raw_body, request.inputs, execution_context) if not ok: raise HTTPException(status_code=status_code, detail=error_msg) @@ -324,8 +358,40 @@ async def start_task( logger.warning(f"Task start failed: Plugin not found: {request.plugin_id}") raise HTTPException(status_code=404, detail=f"Plugin not found: {request.plugin_id}") - # Server-controlled safe mode: never trust client-supplied `inputs.safe_mode`. - safe_mode = bool(settings.safe_mode_default) + db = await get_db() + target_policy = await get_target_policy(db, owner, execution_context.get("target_policy_id")) + credential_profile = await get_credential_profile(db, owner, execution_context.get("credential_profile_id")) + session_profile = await get_session_profile(db, owner, execution_context.get("session_profile_id")) + + if execution_context.get("target_policy_id") and not target_policy: + raise HTTPException(status_code=400, detail="Target policy not found for this workspace") + if execution_context.get("credential_profile_id") and not credential_profile: + raise HTTPException(status_code=400, detail="Credential profile not found for this workspace") + if execution_context.get("session_profile_id") and not session_profile: + raise HTTPException(status_code=400, detail="Session profile not found for this workspace") + + if (credential_profile or session_profile) and not (target_policy and target_policy.get("allow_authenticated_scan")): + raise HTTPException( + status_code=400, + detail="Authenticated scans require a target policy with authenticated scanning enabled.", + ) + + requires_exploit_policy = ( + plugin.safety.get("level") == "exploit" + or execution_context.get("validation_mode") == ValidationMode.CONTROLLED_EXTRACT.value + ) + + if requires_exploit_policy and not (target_policy and target_policy.get("allow_exploit_validation")): + raise HTTPException( + status_code=400, + detail="Offensive validation requires a target policy that explicitly allows exploit validation.", + ) + + # Server-controlled safe mode: public-target scans are opt-in via target policy. + safe_mode = bool( + settings.safe_mode_default + and not (target_policy and target_policy.get("allow_public_targets")) + ) # Ensure downstream scanners/plugins see the effective safe-mode, but prevent client override. effective_inputs = dict(request.inputs or {}) @@ -387,7 +453,9 @@ async def start_task( effective_inputs, safe_mode=safe_mode, preset=request.preset, + execution_context=execution_context, consent_granted=request.consent_granted, + owner_id=owner, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e @@ -418,8 +486,11 @@ async def start_task( } @router.get("/task/{task_id}/status") -async def get_task_status(task_id: str): +async def get_task_status(task_id: str, owner: str = Depends(get_current_owner)): """Get task status""" + db = await get_db() + await require_owned_task(db, task_id, owner) + status = await executor.get_task_status(task_id) if not status: @@ -428,10 +499,13 @@ async def get_task_status(task_id: str): return status @router.get("/task/{task_id}/stream") -async def stream_task_output(task_id: str): +async def stream_task_output(task_id: str, owner: str = Depends(get_current_owner)): """Stream task output via Server-Sent Events (SSE)""" import asyncio + db = await get_db() + await require_owned_task(db, task_id, owner) + status = await executor.get_task_status(task_id) if not status: raise HTTPException(status_code=404, detail="Task not found") @@ -490,17 +564,20 @@ async def event_generator(): return EventSourceResponse(event_generator()) @router.get("/task/{task_id}/report/csv", dependencies=[Depends(report_download_limiter)]) -async def download_csv_report(task_id: str): +async def download_csv_report(task_id: str, owner: str = Depends(get_current_owner)): """Download task results as a CSV report.""" db = await get_db() task_row = await db.fetchone( - "SELECT id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", + "SELECT id, owner_id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", (task_id,) ) if not task_row: raise HTTPException(status_code=404, detail="Task not found") + if task_row["owner_id"] != owner: + raise HTTPException(status_code=403, detail="You do not have access to this task") + if task_row["status"] not in ["completed", "failed"]: raise HTTPException(status_code=400, detail="Task is not finished yet") @@ -525,17 +602,20 @@ async def download_csv_report(task_id: str): ) @router.get("/task/{task_id}/report/html", dependencies=[Depends(report_download_limiter)]) -async def download_html_report(task_id: str): +async def download_html_report(task_id: str, owner: str = Depends(get_current_owner)): """Download task results as an HTML report.""" db = await get_db() task_row = await db.fetchone( - "SELECT id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", + "SELECT id, owner_id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", (task_id,) ) if not task_row: raise HTTPException(status_code=404, detail="Task not found") + if task_row["owner_id"] != owner: + raise HTTPException(status_code=403, detail="You do not have access to this task") + if task_row["status"] not in ["completed", "failed"]: raise HTTPException(status_code=400, detail="Task is not finished yet") @@ -560,17 +640,20 @@ async def download_html_report(task_id: str): ) @router.get("/task/{task_id}/report/pdf", dependencies=[Depends(report_download_limiter)]) -async def download_pdf_report(task_id: str): +async def download_pdf_report(task_id: str, owner: str = Depends(get_current_owner)): """Download task results as a PDF report.""" db = await get_db() task_row = await db.fetchone( - "SELECT id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", + "SELECT id, owner_id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", (task_id,) ) if not task_row: raise HTTPException(status_code=404, detail="Task not found") + if task_row["owner_id"] != owner: + raise HTTPException(status_code=403, detail="You do not have access to this task") + if task_row["status"] not in ["completed", "failed"]: raise HTTPException(status_code=400, detail="Task is not finished yet") @@ -596,17 +679,20 @@ async def download_pdf_report(task_id: str): @router.get("/task/{task_id}/report/sarif", dependencies=[Depends(report_download_limiter)]) -async def download_sarif_report(task_id: str): +async def download_sarif_report(task_id: str, owner: str = Depends(get_current_owner)): """Download task results as a SARIF report.""" db = await get_db() task_row = await db.fetchone( - "SELECT id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", + "SELECT id, owner_id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", (task_id,) ) if not task_row: raise HTTPException(status_code=404, detail="Task not found") + if task_row["owner_id"] != owner: + raise HTTPException(status_code=403, detail="You do not have access to this task") + if task_row["status"] not in ["completed", "failed"]: raise HTTPException(status_code=400, detail="Task is not finished yet") @@ -632,14 +718,14 @@ async def download_sarif_report(task_id: str): @router.get("/task/{task_id}/result") -async def get_task_result(task_id: str): +async def get_task_result(task_id: str, owner: str = Depends(get_current_owner)): """Get task execution result""" db = await get_db() task_row = await db.fetchone( """ - SELECT id, plugin_id, tool_name, target, status, - created_at, duration_seconds, structured_json, preset, inputs_json, + SELECT id, owner_id, plugin_id, tool_name, target, status, + created_at, duration_seconds, structured_json, preset, inputs_json, execution_context_json, raw_output_path, command_used, error_message, exit_code FROM tasks WHERE id = ? """, @@ -649,6 +735,9 @@ async def get_task_result(task_id: str): if not task_row: raise HTTPException(status_code=404, detail="Task not found") + if task_row["owner_id"] != owner: + raise HTTPException(status_code=403, detail="You do not have access to this task") + structured = {} if task_row["structured_json"]: try: @@ -656,12 +745,45 @@ async def get_task_result(task_id: str): except json.JSONDecodeError: structured = {} - findings = structured.get("findings", []) if isinstance(structured, dict) else [] + finding_rows = await db.fetchall( + "SELECT * FROM findings WHERE owner_id = ? AND task_id = ? ORDER BY (risk_score IS NULL) ASC, risk_score DESC, discovered_at DESC", + (owner, task_id), + ) + findings = deserialize_finding_rows(finding_rows) + asset_rows = await db.fetchall( + "SELECT * FROM asset_services WHERE owner_id = ? AND task_id = ? ORDER BY created_at DESC", + (owner, task_id), + ) + asset_services = deserialize_asset_service_rows(asset_rows) + + if not findings and isinstance(structured, dict): + findings = [item for item in structured.get("findings", []) if isinstance(item, dict)] + severity_counts: Dict[str, int] = {} for finding in findings: severity = str(finding.get("severity", "info")).lower() severity_counts[severity] = severity_counts.get(severity, 0) + 1 + finding_groups = structured.get("finding_groups") if isinstance(structured, dict) else None + if not isinstance(finding_groups, list) or not finding_groups: + finding_groups = build_finding_groups(findings) + + asset_summary = structured.get("asset_summary") if isinstance(structured, dict) else None + if not isinstance(asset_summary, list) or not asset_summary: + asset_summary = build_asset_summary(findings, asset_services) + + scan_diff = structured.get("scan_diff") if isinstance(structured, dict) else None + if not isinstance(scan_diff, dict): + scan_diff = {"new": [], "resolved": [], "changed": [], "summary": {"new_count": 0, "resolved_count": 0, "changed_count": 0}} + + if isinstance(structured, dict): + structured["findings"] = findings + structured["finding_groups"] = finding_groups + structured["asset_summary"] = asset_summary + structured["scan_diff"] = scan_diff + structured["asset_services"] = asset_services + structured["severity_counts"] = severity_counts + structured_summary = structured.get("summary") if isinstance(structured, dict) else None summary: List[str] = [ str(item) for item in structured_summary @@ -702,9 +824,13 @@ async def get_task_result(task_id: str): "status": task_row["status"], "preset": task_row["preset"], "inputs": redact_inputs(json.loads(task_row["inputs_json"] or "{}")), + "execution_context": normalize_execution_context(json.loads(task_row["execution_context_json"] or "{}")), "summary": summary, "severity_counts": severity_counts, "findings": findings, + "finding_groups": finding_groups, + "asset_summary": asset_summary, + "scan_diff": scan_diff, "structured": structured, "raw_output_path": task_row["raw_output_path"], "raw_output_excerpt": raw_output, @@ -718,8 +844,11 @@ async def get_task_result(task_id: str): @router.post("/task/{task_id}/cancel") -async def cancel_task(task_id: str): +async def cancel_task(task_id: str, owner: str = Depends(get_current_owner)): """Cancel a running task""" + db = await get_db() + await require_owned_task(db, task_id, owner) + cancelled = await executor.cancel_task(task_id) if not cancelled: @@ -733,20 +862,24 @@ async def cancel_task(task_id: str): @router.get("/dashboard/summary", dependencies=[Depends(read_heavy_limiter)]) -async def get_dashboard_summary(): - """Return aggregate dashboard data from the primary store, cached in Redis.""" +async def get_dashboard_summary(owner: str = Depends(get_current_owner)): + """Return the caller's aggregate dashboard data, cached per owner.""" async def build(): db = await get_db() # Get data - # Push severity aggregation to DB — avoids full table scan in Python + # Push severity aggregation to DB — avoids full table scan in Python. + # Every aggregate below is scoped to the caller so the dashboard never + # surfaces another user/workspace's tasks or findings (issue #401). severity_rows = await db.fetchall( """ SELECT severity, COUNT(*) AS cnt FROM findings + WHERE owner_id = ? GROUP BY severity - """ + """, + (owner,), ) severity_counts = {row["severity"]: row["cnt"] for row in severity_rows} @@ -757,10 +890,14 @@ async def build(): COUNT(*) FILTER (WHERE status = 'completed') AS completed, COUNT(*) FILTER (WHERE status = 'running') AS running FROM tasks - """ + WHERE owner_id = ? + """, + (owner,), ) - total_findings_row = await db.fetchone("SELECT COUNT(*) AS total FROM findings") + total_findings_row = await db.fetchone( + "SELECT COUNT(*) AS total FROM findings WHERE owner_id = ?", (owner,) + ) total_findings = total_findings_row["total"] if total_findings_row else 0 critical_findings: int = severity_counts.get("critical", 0) @@ -774,13 +911,29 @@ async def build(): """ SELECT id, title, category, severity, target, description, remediation, proof, cvss, cve, discovered_at, - risk_score, risk_factors_json, metadata_json + validated, validation_method, confidence_reason, + service_fingerprint, cpe, risk_score, risk_factors_json, + evidence_json, asset_refs_json, references_json, metadata_json FROM findings + WHERE owner_id = ? ORDER BY discovered_at DESC LIMIT 5 - """ + """, + (owner,), ) - recent_findings: List[Dict] = parse_json_fields(recent_rows, ["metadata_json"]) + recent_findings: List[Dict] = parse_json_fields( + recent_rows, + ["metadata_json", "risk_factors_json", "evidence_json", "asset_refs_json", "references_json"], + ) + for finding in recent_findings: + if "risk_factors_json" in finding: + finding["risk_factors"] = finding.pop("risk_factors_json") + if "evidence_json" in finding: + finding["evidence"] = finding.pop("evidence_json") + if "asset_refs_json" in finding: + finding["asset_refs"] = finding.pop("asset_refs_json") + if "references_json" in finding: + finding["references"] = finding.pop("references_json") risk_scores = [ f.get("risk_score") for f in recent_findings @@ -805,47 +958,92 @@ async def build(): }, "running_tasks": parse_json_fields( await db.fetchall( - "SELECT id, plugin_id, tool_name, target, status, created_at FROM tasks WHERE status = 'running' ORDER BY created_at DESC LIMIT 5" + "SELECT id, plugin_id, tool_name, target, status, created_at FROM tasks WHERE owner_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 5", + (owner,), ), [] ), "recent_tasks": parse_json_fields( await db.fetchall( - "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds FROM tasks ORDER BY created_at DESC LIMIT 5" + "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds FROM tasks WHERE owner_id = ? ORDER BY created_at DESC LIMIT 5", + (owner,), ), [] ) } - return await get_or_set_cached("summary:dashboard", build) + return await get_or_set_cached(f"summary:dashboard:{owner}", build) @router.get("/findings", dependencies=[Depends(read_heavy_limiter)]) -async def get_findings(): - """Return vulnerability findings.""" +async def get_findings(owner: str = Depends(get_current_owner)): + """Return the caller's vulnerability findings.""" + + async def build(): + db = await get_db() + rows = await db.fetchall( + "SELECT * FROM findings WHERE owner_id = ? ORDER BY discovered_at DESC", + (owner,), + ) + findings = deserialize_finding_rows(rows) + return {"findings": findings, "finding_groups": build_finding_groups(findings)} + + # Cache key is namespaced by owner so one user's list is never served to + # another (issue #401). + return await get_or_set_cached(f"findings:list:{owner}", build) + +@router.get("/finding-groups", dependencies=[Depends(read_heavy_limiter)]) +async def get_finding_groups(owner: str = Depends(get_current_owner)): async def build(): db = await get_db() - rows = await db.fetchall("SELECT * FROM findings ORDER BY discovered_at DESC") - findings = parse_json_fields(rows, ["metadata_json", "risk_factors_json"]) - for f in findings: - if "risk_factors_json" in f: - f["risk_factors"] = f.pop("risk_factors_json") - return {"findings": findings} + rows = await db.fetchall( + "SELECT * FROM findings WHERE owner_id = ? ORDER BY discovered_at DESC", + (owner,), + ) + findings = deserialize_finding_rows(rows) + return {"groups": build_finding_groups(findings), "total": len(findings)} - return await get_or_set_cached("findings:list", build) + return await get_or_set_cached(f"findings:groups:{owner}", build) + + +@router.get("/task/{task_id}/diff", dependencies=[Depends(read_heavy_limiter)]) +async def get_task_diff(task_id: str, owner: str = Depends(get_current_owner)): + db = await get_db() + task_row = await db.fetchone( + "SELECT owner_id, structured_json FROM tasks WHERE id = ?", + (task_id,), + ) + if not task_row: + raise HTTPException(status_code=404, detail="Task not found") + if task_row["owner_id"] != owner: + raise HTTPException(status_code=403, detail="You do not have access to this task") + + structured = {} + if task_row["structured_json"]: + try: + structured = json.loads(task_row["structured_json"]) + except json.JSONDecodeError: + structured = {} + diff = structured.get("scan_diff") if isinstance(structured, dict) else None + if not isinstance(diff, dict): + diff = {"new": [], "resolved": [], "changed": [], "summary": {"new_count": 0, "resolved_count": 0, "changed_count": 0}} + return diff @router.get("/reports", dependencies=[Depends(read_heavy_limiter)]) -async def get_reports(): - """Return generated reports.""" +async def get_reports(owner: str = Depends(get_current_owner)): + """Return the caller's generated reports.""" async def build(): db = await get_db() - rows = await db.fetchall("SELECT * FROM reports ORDER BY generated_at DESC") + rows = await db.fetchall( + "SELECT * FROM reports WHERE owner_id = ? ORDER BY generated_at DESC", + (owner,), + ) return {"reports": parse_json_fields(rows, ["metadata_json"])} - return await get_or_set_cached("reports:list", build) + return await get_or_set_cached(f"reports:list:{owner}", build) @router.get("/tasks", dependencies=[Depends(read_heavy_limiter)]) @@ -853,25 +1051,35 @@ async def list_tasks( page: int = Query(1, ge=1), per_page: int = Query(25, ge=1, le=100), plugin_id: Optional[str] = None, - status: Optional[str] = None + status: Optional[str] = None, + owner: str = Depends(get_current_owner), ): - """List all tasks with pagination""" + """List the caller's tasks with pagination""" db = await get_db() - # Build query - query = "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds, inputs_json, preset, error_message, exit_code FROM tasks" - params = [] + # Build query — always scoped to the caller so listing can never enumerate + # another user/workspace's tasks (issue #401). + query = "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds, inputs_json, execution_context_json, preset, error_message, exit_code FROM tasks" + params = [owner] - where_clauses = [] + where_clauses = ["owner_id = ?"] if plugin_id: where_clauses.append("plugin_id = ?") params.append(plugin_id) if status: + try: + status = TaskStatus(status).value + except ValueError: + allowed_values = ", ".join([s.value for s in TaskStatus]) + raise HTTPException( + status_code=400, + detail=f"Invalid task status '{status}'. Allowed values: {allowed_values}" + ) + where_clauses.append("status = ?") params.append(status) - if where_clauses: - query += " WHERE " + " AND ".join(where_clauses) + query += " WHERE " + " AND ".join(where_clauses) query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" params.extend([per_page, (page - 1) * per_page]) @@ -887,11 +1095,12 @@ async def list_tasks( total: int = int(count_result["total"]) if count_result and count_result.get("total") is not None else 0 # Parse JSON fields and format for frontend - tasks_list = parse_json_fields(tasks, ["structured_json", "config_json", "metadata_json", "inputs_json"]) + tasks_list = parse_json_fields(tasks, ["structured_json", "config_json", "metadata_json", "inputs_json", "execution_context_json"]) for t in tasks_list: if "id" in t: t["task_id"] = t.pop("id") t["inputs"] = redact_inputs(t.pop("inputs_json", {}) or {}) + t["execution_context"] = t.pop("execution_context_json", {}) or {} total_pages = (total + per_page - 1) // per_page if per_page > 0 else 0 @@ -899,19 +1108,18 @@ async def list_tasks( next_page = page + 1 if page < total_pages else None prev_page = page - 1 if page > 1 else None - # Function to build URL with all query parameters def build_page_url(page_num): if page_num is None: return None - # Start with page and per_page - params_list = [f"page={page_num}", f"per_page={per_page}"] - # Add filters if they exist + query_params = { + "page": page_num, + "per_page": per_page, + } if plugin_id: - params_list.append(f"plugin_id={plugin_id}") + query_params["plugin_id"] = plugin_id if status: - params_list.append(f"status={status}") - # Join with & and return - return f"/api/v1/tasks?{'&'.join(params_list)}" + query_params["status"] = status + return f"/api/v1/tasks?{urlencode(query_params)}" return { "tasks": tasks_list, "pagination": { @@ -932,6 +1140,9 @@ async def delete_task_records(task_ids: List[str]): Processes IDs in chunks of SQLITE_CHUNK_SIZE to stay under SQLite's SQLITE_LIMIT_VARIABLE_NUMBER = 999 limit. + + The deletion is wrapped in a transaction so that a failure mid-way + (e.g. crash, constraint violation) does not leave orphaned records. """ if not task_ids: return @@ -949,16 +1160,50 @@ async def delete_task_records(task_ids: List[str]): ) all_task_rows.extend(rows) - # Delete associated records in chunks - for i in range(0, len(task_ids), SQLITE_CHUNK_SIZE): - chunk = task_ids[i : i + SQLITE_CHUNK_SIZE] - placeholders = ",".join(["?"] * len(chunk)) - await db.execute(f"DELETE FROM findings WHERE task_id IN ({placeholders})", tuple(chunk)) - await db.execute(f"DELETE FROM reports WHERE task_id IN ({placeholders})", tuple(chunk)) - await db.execute(f"DELETE FROM audit_log WHERE task_id IN ({placeholders})", tuple(chunk)) - await db.execute(f"DELETE FROM tasks WHERE id IN ({placeholders})", tuple(chunk)) + # Delete associated records in chunks, atomic within a transaction + await db.begin() + try: + # Re-check running status inside the transaction to prevent the + # race where a task starts running between the check and the delete. + for i in range(0, len(task_ids), SQLITE_CHUNK_SIZE): + chunk = task_ids[i : i + SQLITE_CHUNK_SIZE] + placeholders = ",".join(["?"] * len(chunk)) + running = await db.fetchone( + f"SELECT 1 FROM tasks WHERE id IN ({placeholders}) AND status = 'running' LIMIT 1", + tuple(chunk) + ) + if running: + raise HTTPException( + status_code=400, + detail="Cannot delete running tasks. Abort them first." + ) - # Cleanup files on disk + for i in range(0, len(task_ids), SQLITE_CHUNK_SIZE): + chunk = task_ids[i : i + SQLITE_CHUNK_SIZE] + placeholders = ",".join(["?"] * len(chunk)) + await db.execute_no_commit( + f"DELETE FROM findings WHERE task_id IN ({placeholders})", tuple(chunk) + ) + await db.execute_no_commit( + f"DELETE FROM reports WHERE task_id IN ({placeholders})", tuple(chunk) + ) + await db.execute_no_commit( + f"DELETE FROM audit_log WHERE task_id IN ({placeholders})", tuple(chunk) + ) + await db.execute_no_commit( + f"DELETE FROM tasks WHERE id IN ({placeholders})", tuple(chunk) + ) + await db.commit() + except HTTPException: + await db.rollback() + raise + except Exception: + await db.rollback() + raise + + # Cleanup files on disk (outside the transaction — file deletion is not + # transactional; a failure here does not leave the DB in an inconsistent + # state). for row in all_task_rows: if row and row["raw_output_path"]: try: @@ -969,15 +1214,26 @@ async def delete_task_records(task_ids: List[str]): logger.error(f"Failed to delete raw output file {row['raw_output_path']}: {e}") @router.delete("/task/{task_id}") -async def delete_task(task_id: str): +async def delete_task(task_id: str, owner: str = Depends(get_current_owner)): """Delete a task and its associated data (findings, reports, audit logs, and files)""" db = await get_db() + # Deleting a non-existent task stays idempotent (200, deletes zero rows), + # but a task owned by another user/workspace is rejected with 403 so it + # cannot be deleted across owners (issue #401). + existing = await db.fetchone("SELECT owner_id FROM tasks WHERE id = ?", (task_id,)) + if existing is not None and existing["owner_id"] != owner: + raise HTTPException(status_code=403, detail="You do not have access to this task") + # Check if task is running status = await executor.get_task_status(task_id) if status and status.get("status") == "running": raise HTTPException(status_code=400, detail="Cannot delete a running task. Abort it first.") + # If the task is currently executing but the DB hasn't been updated yet, fail closed. + if task_id in executor.running_tasks: + raise HTTPException(status_code=400, detail="Cannot delete a running task. Abort it first.") + await delete_task_records([task_id]) await invalidate_view_cache() @@ -988,7 +1244,7 @@ async def delete_task(task_id: str): @router.delete("/tasks/bulk") -async def bulk_delete_tasks(request: BulkDeleteRequest): +async def bulk_delete_tasks(request: BulkDeleteRequest, owner: str = Depends(get_current_owner)): """Delete multiple tasks at once (max 500 IDs per request)""" task_ids = request.root # RootModel exposes data via .root db = await get_db() @@ -997,58 +1253,65 @@ async def bulk_delete_tasks(request: BulkDeleteRequest): if not task_ids: return {"deleted_count": 0, "success": True} - # Check running tasks — safe: len(task_ids) <= 500 guaranteed by Pydantic + # Scope to tasks owned by the caller. IDs owned by another user/workspace + # are silently ignored so cross-user enumeration and deletion are + # impossible (issue #401). len(task_ids) <= 500 guaranteed by Pydantic. placeholders = ",".join(["?"] * len(task_ids)) + owned_rows = await db.fetchall( + f"SELECT id FROM tasks WHERE id IN ({placeholders}) AND owner_id = ?", + tuple(task_ids) + (owner,), + ) + owned_ids = [row["id"] for row in owned_rows] + if not owned_ids: + return {"deleted_count": 0, "success": True} + + # Check running tasks among the caller's own tasks + placeholders = ",".join(["?"] * len(owned_ids)) running_tasks = await db.fetchone( f"SELECT id FROM tasks WHERE id IN ({placeholders}) AND status = 'running' LIMIT 1", - tuple(task_ids) + tuple(owned_ids) ) if running_tasks: raise HTTPException(status_code=400, detail="Cannot delete running tasks. Abort them first.") # If the task is currently executing but the DB hasn't been updated yet, fail closed. - if any(tid in executor.running_tasks for tid in task_ids): + if any(tid in executor.running_tasks for tid in owned_ids): raise HTTPException(status_code=400, detail="Cannot delete running tasks. Abort them first.") - await delete_task_records(task_ids) + await delete_task_records(owned_ids) await invalidate_view_cache() return { - "deleted_count": len(task_ids), + "deleted_count": len(owned_ids), "success": True } @router.delete("/tasks/clear") -async def clear_all_tasks(): - """Wipe all scan history and associated data (findings, reports, assets, attack surface)""" +async def clear_all_tasks(owner: str = Depends(get_current_owner)): + """Wipe the caller's scan history and associated data (findings, reports). + + Scoped to the requesting user/workspace so one owner cannot purge another + owner's history (issue #401). + """ db = await get_db() - # Prevent clearing if any tasks are running - running_tasks = await db.fetchone("SELECT id FROM tasks WHERE status = 'running' LIMIT 1") + # Prevent clearing if any of the caller's tasks are running + running_tasks = await db.fetchone( + "SELECT id FROM tasks WHERE owner_id = ? AND status = 'running' LIMIT 1", + (owner,), + ) if running_tasks: raise HTTPException(status_code=400, detail="Cannot clear history while tasks are running.") - # Get all task IDs to cleanup files - all_tasks = await db.fetchall("SELECT id FROM tasks") - task_ids = [t["id"] for t in all_tasks] + # Get the caller's task IDs to delete records and cleanup files + own_tasks = await db.fetchall("SELECT id FROM tasks WHERE owner_id = ?", (owner,)) + task_ids = [t["id"] for t in own_tasks] if task_ids: await delete_task_records(task_ids) - # Purge other tables - await db.execute("DELETE FROM findings") - - # Fallback cleanup for any orphaned files in data directories - for subdir in ["raw", "reports"]: - dir_path = Path(settings.data_dir) / subdir - if dir_path.exists(): - for item in dir_path.iterdir(): - try: - if item.is_file(): - item.unlink() - elif item.is_dir(): - shutil.rmtree(item) - except Exception as e: - logger.error(f"Failed to cleanup {item}: {e}") + # Sweep up any of the caller's findings not linked to a task (task_id was + # set NULL by ON DELETE) so nothing of theirs is left behind. + await db.execute("DELETE FROM findings WHERE owner_id = ?", (owner,)) await invalidate_view_cache() @@ -1079,6 +1342,11 @@ async def get_settings(): "require_consent": settings.require_consent, "safe_mode_default": settings.safe_mode_default, "allowed_networks": settings.allowed_networks + }, + "execution_context": { + "validation_modes": [mode.value for mode in ValidationMode], + "evidence_levels": [level.value for level in EvidenceLevel], + "default": ExecutionContext().model_dump(), } } @@ -1134,22 +1402,263 @@ async def delete_vault_secret(name: str): return {"name": name, "deleted": True} +@router.get("/target-policies") +async def list_target_policies(owner: str = Depends(get_current_owner)): + db = await get_db() + rows = await db.fetchall( + "SELECT * FROM target_policies WHERE owner_id = ? ORDER BY updated_at DESC, created_at DESC", + (owner,), + ) + return {"items": deserialize_resource_rows(rows), "total": len(rows)} + + +@router.post("/target-policies") +async def create_target_policy(payload: Dict[str, Any], owner: str = Depends(get_current_owner)): + name = str(payload.get("name", "")).strip() + if not name: + raise HTTPException(status_code=400, detail="Target policy name is required") + policy_id = str(uuid.uuid4()) + db = await get_db() + await db.execute( + """ + INSERT INTO target_policies ( + id, owner_id, name, description, allow_public_targets, + allow_exploit_validation, allow_authenticated_scan, default_validation_mode, + allowed_targets_json, metadata_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + policy_id, + owner, + name, + str(payload.get("description", "")).strip() or None, + 1 if payload.get("allow_public_targets") else 0, + 1 if payload.get("allow_exploit_validation") else 0, + 1 if payload.get("allow_authenticated_scan") else 0, + str(payload.get("default_validation_mode") or ValidationMode.PROOF.value), + _json_payload(payload.get("allowed_targets"), "[]"), + _json_payload(payload.get("metadata"), "{}"), + ), + ) + row = await db.fetchone("SELECT * FROM target_policies WHERE id = ?", (policy_id,)) + return deserialize_resource_rows([row])[0] if row else {"id": policy_id} + + +@router.patch("/target-policies/{policy_id}") +async def update_target_policy(policy_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner)): + db = await get_db() + row = await db.fetchone("SELECT id FROM target_policies WHERE id = ? AND owner_id = ?", (policy_id, owner)) + if not row: + raise HTTPException(status_code=404, detail="Target policy not found") + updates: List[str] = [] + params: List[Any] = [] + for key in ("name", "description", "default_validation_mode"): + if key in payload: + updates.append(f"{key} = ?") + params.append(str(payload[key]).strip() if payload[key] is not None else None) + for key in ("allow_public_targets", "allow_exploit_validation", "allow_authenticated_scan"): + if key in payload: + updates.append(f"{key} = ?") + params.append(1 if payload[key] else 0) + if "allowed_targets" in payload: + updates.append("allowed_targets_json = ?") + params.append(_json_payload(payload["allowed_targets"], "[]")) + if "metadata" in payload: + updates.append("metadata_json = ?") + params.append(_json_payload(payload["metadata"], "{}")) + updates.append("updated_at = datetime('now')") + params.extend([policy_id, owner]) + await db.execute(f"UPDATE target_policies SET {', '.join(updates)} WHERE id = ? AND owner_id = ?", tuple(params)) + updated = await db.fetchone("SELECT * FROM target_policies WHERE id = ?", (policy_id,)) + return deserialize_resource_rows([updated])[0] if updated else {"id": policy_id, "updated": True} + + +@router.delete("/target-policies/{policy_id}") +async def delete_target_policy(policy_id: str, owner: str = Depends(get_current_owner)): + db = await get_db() + await db.execute("DELETE FROM target_policies WHERE id = ? AND owner_id = ?", (policy_id, owner)) + return {"id": policy_id, "deleted": True} + + +@router.get("/credential-profiles") +async def list_credential_profiles(owner: str = Depends(get_current_owner)): + db = await get_db() + rows = await db.fetchall( + "SELECT * FROM credential_profiles WHERE owner_id = ? ORDER BY updated_at DESC, created_at DESC", + (owner,), + ) + return {"items": deserialize_resource_rows(rows), "total": len(rows)} + + +@router.post("/credential-profiles") +async def create_credential_profile(payload: Dict[str, Any], owner: str = Depends(get_current_owner)): + name = str(payload.get("name", "")).strip() + if not name: + raise HTTPException(status_code=400, detail="Credential profile name is required") + profile_id = str(uuid.uuid4()) + db = await get_db() + await db.execute( + """ + INSERT INTO credential_profiles ( + id, owner_id, name, username_secret_name, password_secret_name, + extra_headers_json, login_recipe_json + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + profile_id, + owner, + name, + payload.get("username_secret_name"), + payload.get("password_secret_name"), + _json_payload(payload.get("extra_headers"), "{}"), + _json_payload(payload.get("login_recipe"), "{}"), + ), + ) + row = await db.fetchone("SELECT * FROM credential_profiles WHERE id = ?", (profile_id,)) + return deserialize_resource_rows([row])[0] if row else {"id": profile_id} + + +@router.patch("/credential-profiles/{profile_id}") +async def update_credential_profile(profile_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner)): + db = await get_db() + row = await db.fetchone("SELECT id FROM credential_profiles WHERE id = ? AND owner_id = ?", (profile_id, owner)) + if not row: + raise HTTPException(status_code=404, detail="Credential profile not found") + updates: List[str] = [] + params: List[Any] = [] + for key in ("name", "username_secret_name", "password_secret_name"): + if key in payload: + updates.append(f"{key} = ?") + params.append(payload[key]) + if "extra_headers" in payload: + updates.append("extra_headers_json = ?") + params.append(_json_payload(payload["extra_headers"], "{}")) + if "login_recipe" in payload: + updates.append("login_recipe_json = ?") + params.append(_json_payload(payload["login_recipe"], "{}")) + updates.append("updated_at = datetime('now')") + params.extend([profile_id, owner]) + await db.execute(f"UPDATE credential_profiles SET {', '.join(updates)} WHERE id = ? AND owner_id = ?", tuple(params)) + updated = await db.fetchone("SELECT * FROM credential_profiles WHERE id = ?", (profile_id,)) + return deserialize_resource_rows([updated])[0] if updated else {"id": profile_id, "updated": True} + + +@router.delete("/credential-profiles/{profile_id}") +async def delete_credential_profile(profile_id: str, owner: str = Depends(get_current_owner)): + db = await get_db() + await db.execute("DELETE FROM credential_profiles WHERE id = ? AND owner_id = ?", (profile_id, owner)) + return {"id": profile_id, "deleted": True} + + +@router.get("/session-profiles") +async def list_session_profiles(owner: str = Depends(get_current_owner)): + db = await get_db() + rows = await db.fetchall( + "SELECT * FROM session_profiles WHERE owner_id = ? ORDER BY updated_at DESC, created_at DESC", + (owner,), + ) + return {"items": deserialize_resource_rows(rows), "total": len(rows)} + + +@router.post("/session-profiles") +async def create_session_profile(payload: Dict[str, Any], owner: str = Depends(get_current_owner)): + name = str(payload.get("name", "")).strip() + if not name: + raise HTTPException(status_code=400, detail="Session profile name is required") + profile_id = str(uuid.uuid4()) + db = await get_db() + await db.execute( + """ + INSERT INTO session_profiles ( + id, owner_id, name, cookie_secret_name, extra_headers_json, notes + ) VALUES (?, ?, ?, ?, ?, ?) + """, + ( + profile_id, + owner, + name, + payload.get("cookie_secret_name"), + _json_payload(payload.get("extra_headers"), "{}"), + str(payload.get("notes", "")).strip() or None, + ), + ) + row = await db.fetchone("SELECT * FROM session_profiles WHERE id = ?", (profile_id,)) + return deserialize_resource_rows([row])[0] if row else {"id": profile_id} + + +@router.patch("/session-profiles/{profile_id}") +async def update_session_profile(profile_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner)): + db = await get_db() + row = await db.fetchone("SELECT id FROM session_profiles WHERE id = ? AND owner_id = ?", (profile_id, owner)) + if not row: + raise HTTPException(status_code=404, detail="Session profile not found") + updates: List[str] = [] + params: List[Any] = [] + for key in ("name", "cookie_secret_name", "notes"): + if key in payload: + updates.append(f"{key} = ?") + params.append(payload[key]) + if "extra_headers" in payload: + updates.append("extra_headers_json = ?") + params.append(_json_payload(payload["extra_headers"], "{}")) + updates.append("updated_at = datetime('now')") + params.extend([profile_id, owner]) + await db.execute(f"UPDATE session_profiles SET {', '.join(updates)} WHERE id = ? AND owner_id = ?", tuple(params)) + updated = await db.fetchone("SELECT * FROM session_profiles WHERE id = ?", (profile_id,)) + return deserialize_resource_rows([updated])[0] if updated else {"id": profile_id, "updated": True} + + +@router.delete("/session-profiles/{profile_id}") +async def delete_session_profile(profile_id: str, owner: str = Depends(get_current_owner)): + db = await get_db() + await db.execute("DELETE FROM session_profiles WHERE id = ? AND owner_id = ?", (profile_id, owner)) + return {"id": profile_id, "deleted": True} + + +@router.get("/crawl-runs") +async def list_crawl_runs(owner: str = Depends(get_current_owner)): + db = await get_db() + rows = await db.fetchall( + "SELECT * FROM crawl_runs WHERE owner_id = ? ORDER BY created_at DESC", + (owner,), + ) + return {"items": deserialize_resource_rows(rows), "total": len(rows)} + + +@router.get("/assets/services") +async def list_asset_services(owner: str = Depends(get_current_owner)): + db = await get_db() + rows = await db.fetchall( + "SELECT * FROM asset_services WHERE owner_id = ? ORDER BY created_at DESC", + (owner,), + ) + return {"items": deserialize_asset_service_rows(rows), "total": len(rows)} + + +@router.get("/knowledgebase/status") +async def get_knowledgebase_status(): + return KnowledgeBase().status() + + @router.get("/workflows") -async def list_workflows(): +async def list_workflows(owner: str = Depends(get_current_owner)): db = await get_db() - rows = await db.fetchall("SELECT * FROM workflows ORDER BY created_at DESC") + rows = await db.fetchall( + "SELECT * FROM workflows WHERE owner_id = ? ORDER BY created_at DESC", + (owner,), + ) workflows = [_serialize_workflow(row) for row in rows] return {"workflows": workflows, "total": len(workflows)} @router.post("/workflows") -async def create_workflow(payload: Dict[str, Any]): +async def create_workflow(payload: Dict[str, Any], owner: str = Depends(get_current_owner)): name = str(payload.get("name", "")).strip() if not name: raise HTTPException(status_code=400, detail="Workflow name is required") - steps = payload.get("steps", []) - if not isinstance(steps, list) or not steps: + steps = _parse_workflow_steps(payload.get("steps", [])) + if not steps: raise HTTPException(status_code=400, detail="Workflow requires at least one step") workflow_id = str(uuid.uuid4()) @@ -1158,12 +1667,13 @@ async def create_workflow(payload: Dict[str, Any]): db = await get_db() await db.execute( """ - INSERT INTO workflows (id, name, schedule_seconds, enabled, steps_json) - VALUES (?, ?, ?, ?, ?) + INSERT INTO workflows (id, name, owner_id, schedule_seconds, enabled, steps_json) + VALUES (?, ?, ?, ?, ?, ?) """, ( workflow_id, name, + owner, int(schedule_seconds) if schedule_seconds else None, 1 if enabled else 0, json.dumps(steps), @@ -1173,16 +1683,43 @@ async def create_workflow(payload: Dict[str, Any]): return _serialize_workflow(row) if row else {"id": workflow_id, "created": True} -@router.post("/workflows/{workflow_id}/run") -async def run_workflow_once(workflow_id: str): - db = await get_db() - row = await db.fetchone("SELECT steps_json FROM workflows WHERE id = ?", (workflow_id,)) +async def _verify_workflow_owner(db, workflow_id: str, owner: str): + """Check the workflow exists and belongs to the caller. Returns the row or raises 404/403.""" + row = await db.fetchone( + "SELECT * FROM workflows WHERE id = ?", (workflow_id,) + ) if not row: raise HTTPException(status_code=404, detail="Workflow not found") - steps = json.loads(row["steps_json"] or "[]") + if row["owner_id"] != owner: + raise HTTPException(status_code=403, detail="You do not have access to this workflow") + return row + + +@router.post("/workflows/{workflow_id}/run") +async def run_workflow_once(workflow_id: str, owner: str = Depends(get_current_owner)): + db = await get_db() + row = await _verify_workflow_owner(db, workflow_id, owner) + wf_rate_ok, wf_rate_msg = await workflow_rate_limiter.check_workflow_rate_limit( + workflow_id, settings.workflow_min_interval_seconds + ) + if not wf_rate_ok: + raise HTTPException(status_code=429, detail=wf_rate_msg) + steps = _parse_workflow_steps(row["steps_json"] or "[]") + active_version = await db.fetchone( + "SELECT id, version_number FROM workflow_versions " + "WHERE workflow_id = ? ORDER BY version_number DESC LIMIT 1", + (workflow_id,), + ) + version_id = active_version["id"] if active_version else None + version_number = active_version["version_number"] if active_version else None created_task_ids: List[str] = [] for step in steps: - safe_mode = bool(settings.safe_mode_default) + execution_context = normalize_execution_context(step.get("execution_context") or {}) + target_policy = await get_target_policy(db, owner, execution_context.get("target_policy_id")) + safe_mode = bool( + settings.safe_mode_default + and not (target_policy and target_policy.get("allow_public_targets")) + ) effective_inputs = dict(step.get("inputs", {}) or {}) effective_inputs.pop("safe_mode", None) effective_inputs["safe_mode"] = safe_mode @@ -1191,24 +1728,126 @@ async def run_workflow_once(workflow_id: str): effective_inputs, safe_mode=safe_mode, preset=step.get("preset"), + execution_context=execution_context, consent_granted=True, + owner_id=owner, ) asyncio.create_task(executor.execute_task(task_id)) created_task_ids.append(task_id) await db.execute("UPDATE workflows SET last_run_at = datetime('now') WHERE id = ?", (workflow_id,)) + run_id = await db.record_workflow_run( + workflow_id=workflow_id, + version_id=version_id, + version_number=version_number, + task_ids=created_task_ids, + triggered_by="manual", + ) + asyncio.create_task(_finalize_workflow_run(run_id)) return { "workflow_id": workflow_id, + "run_id": run_id, + "version_number": version_number, "queued_task_ids": created_task_ids, "queued_tasks": created_task_ids, } +async def _finalize_workflow_run(run_id: str, poll_interval: float = 5.0, max_polls: int = 720) -> None: + """Background task that polls task statuses and marks the run terminal. + + Polls every *poll_interval* seconds for up to *max_polls* iterations + (default: 5 s × 720 = 1 hour). If tasks are still running after the + limit, the run is marked failed with a timeout message so it never stays + permanently in the 'queued' state. + """ + from .database import get_db as _get_db + for _ in range(max_polls): + await asyncio.sleep(poll_interval) + try: + db = await _get_db() + terminal_status = await db.check_workflow_run_tasks(run_id) + if terminal_status is not None: + await db.finalize_workflow_run(run_id, terminal_status) + return + except Exception as exc: + logger.warning("workflow run finalization error for %s: %s", run_id, exc) + return + try: + db = await _get_db() + await db.finalize_workflow_run( + run_id, "failed", "Run finalization timed out — check individual task statuses" + ) + except Exception as exc: + logger.warning("workflow run timeout finalization failed for %s: %s", run_id, exc) + + +@router.get("/workflows/{workflow_id}/runs") +async def list_workflow_runs(workflow_id: str, owner: str = Depends(get_current_owner), limit: int = 50, offset: int = 0): + """Return paginated run history for a workflow.""" + if limit < 1 or limit > 500: + raise HTTPException(status_code=400, detail="limit must be between 1 and 500") + if offset < 0: + raise HTTPException(status_code=400, detail="offset must be non-negative") + db = await get_db() + await _verify_workflow_owner(db, workflow_id, owner) + return await db.get_workflow_runs(workflow_id=workflow_id, limit=limit, offset=offset) + + +@router.get("/workflows/{workflow_id}/versions") +async def list_workflow_versions(workflow_id: str, owner: str = Depends(get_current_owner)): + """Return all saved version snapshots for a workflow, newest first.""" + db = await get_db() + await _verify_workflow_owner(db, workflow_id, owner) + versions = await db.get_workflow_versions(workflow_id=workflow_id) + return {"workflow_id": workflow_id, "versions": versions, "total": len(versions)} + + +@router.post("/workflows/{workflow_id}/rollback/{version_number}") +async def rollback_workflow(workflow_id: str, version_number: int, owner: str = Depends(get_current_owner)): + """Restore a workflow to a previously saved version. + + The target version's full definition replaces the live workflow fields. + A new version snapshot is recorded so the rollback itself is auditable + and can be rolled back in turn. + """ + db = await get_db() + wf = await _verify_workflow_owner(db, workflow_id, owner) + target = await db.get_workflow_version(workflow_id, version_number) + if target is None: + raise HTTPException( + status_code=404, + detail=f"Version {version_number} not found for this workflow", + ) + defn = target["definition"] + name = defn.get("name", wf["name"]) + steps = defn.get("steps", []) + schedule_seconds = defn.get("schedule_seconds") + enabled = bool(defn.get("enabled", True)) + await db.execute( + "UPDATE workflows SET name = ?, steps_json = ?, schedule_seconds = ?, enabled = ? WHERE id = ?", + (name, json.dumps(steps), schedule_seconds, 1 if enabled else 0, workflow_id), + ) + new_version = await db.snapshot_workflow_version( + workflow_id=workflow_id, + name=name, + schedule_seconds=schedule_seconds, + enabled=enabled, + steps=steps, + created_by=f"rollback_to_v{version_number}", + ) + updated = await db.fetchone("SELECT * FROM workflows WHERE id = ?", (workflow_id,)) + return { + "workflow_id": workflow_id, + "rolled_back_to_version": version_number, + "new_version_number": new_version["version_number"], + "workflow": _serialize_workflow(updated) if updated else None, + } + + @router.patch("/workflows/{workflow_id}") -async def update_workflow(workflow_id: str, payload: Dict[str, Any]): +async def update_workflow(workflow_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner)): db = await get_db() - row = await db.fetchone("SELECT id FROM workflows WHERE id = ?", (workflow_id,)) - if not row: - raise HTTPException(status_code=404, detail="Workflow not found") + row = await _verify_workflow_owner(db, workflow_id, owner) updates = [] params: List[Any] = [] @@ -1217,7 +1856,7 @@ async def update_workflow(workflow_id: str, payload: Dict[str, Any]): params.append(str(payload["name"]).strip()) if "steps" in payload: updates.append("steps_json = ?") - params.append(json.dumps(payload["steps"])) + params.append(json.dumps(_parse_workflow_steps(payload["steps"]))) if "schedule_seconds" in payload: val = payload["schedule_seconds"] updates.append("schedule_seconds = ?") @@ -1232,34 +1871,46 @@ async def update_workflow(workflow_id: str, payload: Dict[str, Any]): params.append(workflow_id) await db.execute(f"UPDATE workflows SET {', '.join(updates)} WHERE id = ?", tuple(params)) updated = await db.fetchone("SELECT * FROM workflows WHERE id = ?", (workflow_id,)) - return _serialize_workflow(updated) if updated else {"workflow_id": workflow_id, "updated": True} + if updated is None: + return {"workflow_id": workflow_id, "updated": True} + await db.snapshot_workflow_version( + workflow_id=workflow_id, + name=updated["name"], + schedule_seconds=updated["schedule_seconds"], + enabled=bool(updated["enabled"]), + steps=json.loads(updated["steps_json"] or "[]"), + created_by="patch", + ) + return _serialize_workflow(updated) @router.delete("/workflows/{workflow_id}") -async def delete_workflow(workflow_id: str): +async def delete_workflow(workflow_id: str, owner: str = Depends(get_current_owner)): db = await get_db() + await _verify_workflow_owner(db, workflow_id, owner) await db.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,)) return {"workflow_id": workflow_id, "deleted": True} -@router.post("/workflows/scheduler/tick") +@router.post("/workflows/scheduler/tick", dependencies=[Depends(scheduler_tick_limiter)]) async def trigger_workflow_tick(): await scheduler.tick() return {"tick": "ok"} @router.get("/notifications/rules") -async def list_notification_rules(): +async def list_notification_rules(owner: str = Depends(get_current_owner)): db = await get_db() rows = await db.fetchall( - "SELECT * FROM notification_rules ORDER BY created_at DESC" + "SELECT * FROM notification_rules WHERE owner_id = ? ORDER BY created_at DESC", + (owner,), ) rules = [_serialize_notification_rule(row) for row in rows] return {"rules": rules, "total": len(rules)} @router.post("/notifications/rules") -async def create_notification_rule(payload: NotificationRuleCreate): +async def create_notification_rule(payload: NotificationRuleCreate, owner: str = Depends(get_current_owner)): name = payload.name.strip() if not name: raise HTTPException(status_code=400, detail="Rule name is required") @@ -1270,12 +1921,13 @@ async def create_notification_rule(payload: NotificationRuleCreate): await db.execute( """ INSERT INTO notification_rules ( - id, name, severity_threshold, channel_type, target_url_or_email, is_active - ) VALUES (?, ?, ?, ?, ?, ?) + id, name, owner_id, severity_threshold, channel_type, target_url_or_email, is_active + ) VALUES (?, ?, ?, ?, ?, ?, ?) """, ( rule_id, name, + owner, payload.severity_threshold.value, payload.channel_type.value, target, @@ -1291,27 +1943,30 @@ async def create_notification_rule(payload: NotificationRuleCreate): return _serialize_notification_rule(row) -@router.get("/notifications/rules/{rule_id}") -async def get_notification_rule(rule_id: str): - db = await get_db() +async def _verify_notification_rule_owner(db, rule_id: str, owner: str): + """Check the notification rule exists and belongs to the caller.""" row = await db.fetchone( "SELECT * FROM notification_rules WHERE id = ?", (rule_id,), ) if not row: raise HTTPException(status_code=404, detail="Notification rule not found") + if row["owner_id"] != owner: + raise HTTPException(status_code=403, detail="You do not have access to this notification rule") + return row + + +@router.get("/notifications/rules/{rule_id}") +async def get_notification_rule(rule_id: str, owner: str = Depends(get_current_owner)): + db = await get_db() + row = await _verify_notification_rule_owner(db, rule_id, owner) return _serialize_notification_rule(row) @router.patch("/notifications/rules/{rule_id}") -async def update_notification_rule(rule_id: str, payload: NotificationRuleUpdate): +async def update_notification_rule(rule_id: str, payload: NotificationRuleUpdate, owner: str = Depends(get_current_owner)): db = await get_db() - row = await db.fetchone( - "SELECT * FROM notification_rules WHERE id = ?", - (rule_id,), - ) - if not row: - raise HTTPException(status_code=404, detail="Notification rule not found") + row = await _verify_notification_rule_owner(db, rule_id, owner) updates: List[str] = [] params: List[Any] = [] @@ -1374,14 +2029,9 @@ async def update_notification_rule(rule_id: str, payload: NotificationRuleUpdate @router.delete("/notifications/rules/{rule_id}") -async def delete_notification_rule(rule_id: str): +async def delete_notification_rule(rule_id: str, owner: str = Depends(get_current_owner)): db = await get_db() - row = await db.fetchone( - "SELECT id FROM notification_rules WHERE id = ?", - (rule_id,), - ) - if not row: - raise HTTPException(status_code=404, detail="Notification rule not found") + await _verify_notification_rule_owner(db, rule_id, owner) await db.execute("DELETE FROM notification_rules WHERE id = ?", (rule_id,)) return {"rule_id": rule_id, "deleted": True} @@ -1421,7 +2071,7 @@ async def list_notification_history( @router.get("/finding/{finding_id}") -async def get_finding_details(finding_id: str): +async def get_finding_details(finding_id: str, owner: str = Depends(get_current_owner)): """Get detailed information for a specific finding""" db = await get_db() @@ -1438,6 +2088,9 @@ async def get_finding_details(finding_id: str): if not finding_row: raise HTTPException(status_code=404, detail="Finding not found") + if finding_row["owner_id"] != owner: + raise HTTPException(status_code=403, detail="You do not have access to this finding") + metadata = {} if finding_row["metadata_json"]: try: @@ -1477,13 +2130,19 @@ async def get_finding_details(finding_id: str): @router.get("/attack-surface") -async def get_attack_surface(): - """Return an aggregated view of the monitored attack surface.""" +async def get_attack_surface(owner: str = Depends(get_current_owner)): + """Return an aggregated view of the caller's monitored attack surface.""" db = await get_db() - # We aggregate unique targets from tasks and findings - tasks = await db.fetchall("SELECT DISTINCT target, tool_name, created_at FROM tasks ORDER BY created_at DESC") - findings = await db.fetchall("SELECT DISTINCT target, category, severity, discovered_at FROM findings ORDER BY discovered_at DESC") + # We aggregate unique targets from the caller's own tasks and findings + tasks = await db.fetchall( + "SELECT DISTINCT target, tool_name, created_at FROM tasks WHERE owner_id = ? ORDER BY created_at DESC", + (owner,), + ) + findings = await db.fetchall( + "SELECT DISTINCT target, category, severity, discovered_at FROM findings WHERE owner_id = ? ORDER BY discovered_at DESC", + (owner,), + ) entries = [] seen_targets = set() @@ -1522,10 +2181,149 @@ async def get_attack_surface(): @router.get("/assets") -async def get_assets(): - """Return a list of tracked assets.""" +async def get_assets(owner: str = Depends(get_current_owner)): + """Return a list of the caller's tracked assets.""" db = await get_db() - # For now, we use unique targets as assets - rows = await db.fetchall("SELECT DISTINCT target FROM tasks UNION SELECT DISTINCT target FROM findings") + # For now, we use unique targets as assets, scoped to the caller (issue #401) + rows = await db.fetchall( + """ + SELECT DISTINCT target FROM tasks WHERE owner_id = ? + UNION + SELECT DISTINCT target FROM findings WHERE owner_id = ? + """, + (owner, owner), + ) assets = [{"id": str(uuid.uuid4()), "name": row["target"]} for row in rows] return {"assets": assets} + +# ── Network Policy Management Endpoints ───────────────────────────────────── + +from fastapi.security import APIKeyHeader +from fastapi import Security, status +from .network_policy import get_policy_engine, PolicyAction +from dataclasses import asdict + +api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + +def verify_admin_access( + api_key: Optional[str] = Security(api_key_header), + request: Request = None, +) -> Optional[str]: + """Verify admin API key is provided and valid.""" + import hmac + + # Secure-by-default: If admin_api_key setting is not configured, block all access + if not settings.admin_api_key: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Admin API Key is not configured on the server. Please set SECUSCAN_ADMIN_API_KEY." + ) + + # Entropy check: enforce a strong API key + if len(settings.admin_api_key) < 16: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Admin API Key is too weak. It must be at least 16 characters long." + ) + + candidate = api_key + if request: + auth_header = request.headers.get("authorization") + if auth_header: + if auth_header.lower().startswith("bearer "): + token = auth_header[7:] + else: + token = auth_header + # If the Authorization header matches the admin API key, prefer it. + # This is important when the client automatically includes the general X-Api-Key in headers. + if hmac.compare_digest(token, settings.admin_api_key): + candidate = token + elif not candidate: + candidate = token + + if not candidate or not hmac.compare_digest(candidate, settings.admin_api_key): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing Admin API Key" + ) + return candidate + +@router.get("/admin/network-policy", dependencies=[Depends(verify_admin_access), Depends(admin_limiter)]) +async def get_network_policy(): + """Get current network policy configuration""" + engine = get_policy_engine() + + return { + "allowlist": [asdict(p) for net, p in engine.allowlist], + "denylist": [asdict(p) for net, p in engine.denylist], + "audit_entries_count": len(engine.audit_entries), + } + +@router.post("/admin/network-policy/allow", dependencies=[Depends(verify_admin_access), Depends(admin_limiter)]) +async def add_allow_rule(request: dict): + """Add network to allowlist""" + engine = get_policy_engine() + + try: + engine.add_allow_rule( + cidr=request["cidr"], + reason=request.get("reason", "Operator added"), + ) + return {"status": "success", "cidr": request["cidr"]} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.post("/admin/network-policy/deny", dependencies=[Depends(verify_admin_access), Depends(admin_limiter)]) +async def add_deny_rule(request: dict): + """Add network to denylist""" + engine = get_policy_engine() + + try: + engine.add_deny_rule( + cidr=request["cidr"], + reason=request.get("reason", "Operator added"), + ) + return {"status": "success", "cidr": request["cidr"]} + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + +@router.get("/admin/network-audit-log", dependencies=[Depends(verify_admin_access), Depends(admin_limiter)]) +async def get_audit_log( + plugin_id: Optional[str] = None, + action: Optional[str] = None, + limit: int = 100 +): + """Retrieve network audit log entries""" + engine = get_policy_engine() + + policy_action = None + if action and action.upper() in ["ALLOW", "DENY"]: + policy_action = PolicyAction[action.upper()] + + entries = engine.get_audit_entries( + plugin_id=plugin_id, + action=policy_action, + limit=limit + ) + + return { + "entries": [asdict(e) for e in entries], + "total": len(entries), + } + +@router.get("/admin/network-audit-log/export", dependencies=[Depends(verify_admin_access), Depends(admin_limiter)]) +async def export_audit_log(format: str = "json"): + """Export audit log in specified format""" + engine = get_policy_engine() + + if format not in ["json", "csv"]: + raise HTTPException(status_code=400, detail="Format must be 'json' or 'csv'") + + content = engine.export_audit_log(format) + + mime_type = "application/json" if format == "json" else "text/csv" + return Response( + content=content, + media_type=mime_type, + headers={"Content-Disposition": f"attachment; filename=network-audit.{format}"} + ) diff --git a/backend/secuscan/routes_json_helpers.py b/backend/secuscan/routes_json_helpers.py new file mode 100644 index 000000000..722edb675 --- /dev/null +++ b/backend/secuscan/routes_json_helpers.py @@ -0,0 +1,93 @@ +""" +Pure JSON deserialization helpers for routes.py. + +These helpers were originally defined inline in routes.py. They were extracted +into this small import-safe module so that they can be unit-tested directly +without pulling in the heavy routes.py import chain (FastAPI, reporting, +xhtml2pdf, etc.). routes.py re-imports them from here so the public API is +unchanged. + +The functions are pure: they take rows (dicts) and return new lists of dicts. +They never mutate their inputs. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List + + +def parse_json_fields(rows: List[Dict], fields: List[str]) -> List[Dict]: + """Parse stringified JSON fields from a list of row dicts. + + For each row in *rows*, the named *fields* are checked. If a field is + present, truthy, and a string, it is parsed with :func:`json.loads`. + Parsing failures are silently preserved (the original string is kept). + + Args: + rows: Iterable of row dicts (typically from a SQL query). + fields: Column names whose values may be JSON-encoded strings. + + Returns: + A new list of row dicts with the named fields parsed. + """ + parsed = [] + for row in rows: + item = dict(row) + for field in fields: + if item.get(field) and isinstance(item[field], str): + try: + item[field] = json.loads(item[field]) + except json.JSONDecodeError: + pass + parsed.append(item) + return parsed + + +FINDING_JSON_FIELDS = [ + "metadata_json", + "risk_factors_json", + "evidence_json", + "asset_refs_json", + "references_json", + "corroborating_sources_json", +] + + +def deserialize_finding_rows(rows: List[Dict]) -> List[Dict[str, Any]]: + """Parse JSON fields on finding rows and rename them to friendly keys. + + The ``*_json`` suffix is stripped from the parsed values: + ``metadata_json`` -> ``metadata``, ``evidence_json`` -> ``evidence``, etc. + Rows that do not contain a given ``*_json`` key are passed through. + """ + findings = parse_json_fields(rows, FINDING_JSON_FIELDS) + for finding in findings: + if "metadata_json" in finding: + finding["metadata"] = finding.pop("metadata_json") + if "risk_factors_json" in finding: + finding["risk_factors"] = finding.pop("risk_factors_json") + if "evidence_json" in finding: + finding["evidence"] = finding.pop("evidence_json") + if "asset_refs_json" in finding: + finding["asset_refs"] = finding.pop("asset_refs_json") + if "references_json" in finding: + finding["references"] = finding.pop("references_json") + if "corroborating_sources_json" in finding: + finding["corroborating_sources"] = finding.pop("corroborating_sources_json") + return findings + + +def deserialize_asset_service_rows(rows: List[Dict]) -> List[Dict[str, Any]]: + """Parse JSON fields on asset-service rows and rename them. + + Only ``metadata_json`` and ``cert_san_json`` are parsed; both are renamed + to ``metadata`` and ``cert_san`` respectively. + """ + items = parse_json_fields(rows, ["metadata_json", "cert_san_json"]) + for item in items: + if "metadata_json" in item: + item["metadata"] = item.pop("metadata_json") + if "cert_san_json" in item: + item["cert_san"] = item.pop("cert_san_json") + return items diff --git a/backend/secuscan/sandbox_executor.py b/backend/secuscan/sandbox_executor.py new file mode 100644 index 000000000..bb80ef180 --- /dev/null +++ b/backend/secuscan/sandbox_executor.py @@ -0,0 +1,214 @@ +import asyncio +import logging +import platform +from asyncio import subprocess +from typing import List, Optional, Tuple + +from .models import SandboxConfig + +logger = logging.getLogger(__name__) + +IS_LINUX = platform.system() == "Linux" + +CHUNK_SIZE = 64 * 1024 +SIGTERM_GRACE = 3.0 + + +def resolve_sandbox_config(plugin_sandbox: Optional[SandboxConfig] = None) -> SandboxConfig: + """Merge global settings with optional per-plugin sandbox overrides.""" + from .config import settings + base = SandboxConfig( + timeout_seconds=settings.sandbox_timeout, + max_memory_mb=settings.sandbox_memory_mb, + max_output_bytes=settings.sandbox_max_output_bytes, + allow_network=settings.sandbox_allow_network, + ) + if not plugin_sandbox: + return base + overrides = plugin_sandbox.model_dump(exclude_none=True) + return base.model_copy(update=overrides) + + +def _build_preexec_fn(config: SandboxConfig): + """Build preexec_fn for Linux that applies RLIMIT_AS.""" + mem_limit = config.max_memory_mb * 1024 * 1024 + + def _apply_limits(): + import resource + resource.setrlimit(resource.RLIMIT_AS, (mem_limit, mem_limit)) + + return _apply_limits + + +def classify_memory_violation( + exit_code: int, + stderr_text: str, + rss_bytes: int, + limit_bytes: int, +) -> bool: + """Post-mortem heuristic to classify whether failure was caused by memory exhaustion.""" + if exit_code in (-11, 139): + return True + if "MemoryError" in stderr_text or "Cannot allocate memory" in stderr_text: + return True + if rss_bytes >= limit_bytes * 95 // 100 and exit_code != 0: + return True + return False + + +async def _terminate_process(process): + """Graceful SIGTERM -> 3s grace -> SIGKILL escalation. Always reaps.""" + try: + process.terminate() + except ProcessLookupError: + return + try: + await asyncio.wait_for(process.wait(), timeout=SIGTERM_GRACE) + except asyncio.TimeoutError: + try: + process.kill() + except ProcessLookupError: + pass + await process.wait() + + +async def _read_stream(stream, buffer, state, broadcast_callback=None, stream_name=""): + """Read from a stream in 64KB chunks, respecting max_output_bytes limit.""" + while True: + chunk = await stream.read(CHUNK_SIZE) + if not chunk: + break + async with state["lock"]: + if state["limit_hit"]: + return + remaining = state["max_bytes"] - state["total_bytes"] + if remaining <= 0: + state["limit_hit"] = True + return + if len(chunk) > remaining: + chunk = chunk[:remaining] + state["limit_hit"] = True + buffer.extend(chunk) + state["total_bytes"] += len(chunk) + if broadcast_callback: + await broadcast_callback(chunk, stream_name) + + +async def sandbox_execute( + cmd: List[str], + config: SandboxConfig, + broadcast_callback=None, +) -> Tuple[str, str, int, Optional[str]]: + """ + Execute a subprocess under sandbox resource constraints. + + Args: + cmd: Command list to execute. + config: SandboxConfig with timeout, memory, output limits. + When timeout_seconds is 0 or None, no wall-clock timeout is + applied internally (the caller handles it externally). + broadcast_callback: Optional async callable(chunk: bytes, stream_name: str) + invoked for each output chunk to enable live streaming. + + Returns (stdout_str, stderr_str, exit_code, violation_reason). + violation_reason is None on success, or one of + "timeout", "memory_limit", "output_limit". + """ + preexec_fn = _build_preexec_fn(config) if IS_LINUX else None + + rss_before = 0 + try: + import resource + rss_before = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss + except (ImportError, AttributeError): + pass + + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + preexec_fn=preexec_fn, + ) + + stdout_buffer = bytearray() + stderr_buffer = bytearray() + + state = { + "total_bytes": 0, + "max_bytes": config.max_output_bytes, + "limit_hit": False, + "lock": asyncio.Lock(), + } + + violation_reason = None + + reader_task = asyncio.gather( + _read_stream(process.stdout, stdout_buffer, state, broadcast_callback, "stdout"), + _read_stream(process.stderr, stderr_buffer, state, broadcast_callback, "stderr"), + ) + + try: + if config.timeout_seconds: + try: + await asyncio.wait_for(reader_task, timeout=config.timeout_seconds) + except asyncio.TimeoutError: + if state["limit_hit"]: + violation_reason = "output_limit" + else: + violation_reason = "timeout" + reader_task.cancel() + try: + await reader_task + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + await _terminate_process(process) + else: + if state["limit_hit"]: + violation_reason = "output_limit" + await _terminate_process(process) + else: + await process.wait() + else: + await reader_task + if state["limit_hit"]: + violation_reason = "output_limit" + await _terminate_process(process) + else: + await process.wait() + except asyncio.CancelledError: + violation_reason = None + if not reader_task.done(): + reader_task.cancel() + try: + await reader_task + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + raise + finally: + if process.returncode is None: + await _terminate_process(process) + + stdout_str = stdout_buffer.decode("utf-8", errors="replace") + stderr_str = stderr_buffer.decode("utf-8", errors="replace") + exit_code = process.returncode if process.returncode is not None else -1 + + if violation_reason is None and exit_code != 0: + rss_delta = 0 + try: + import resource + rss_after = resource.getrusage(resource.RUSAGE_CHILDREN).ru_maxrss + rss_delta = rss_after - rss_before + except (ImportError, AttributeError): + pass + + if IS_LINUX: + rss_bytes = rss_delta * 1024 + else: + rss_bytes = rss_delta + + limit_bytes = config.max_memory_mb * 1024 * 1024 + + if classify_memory_violation(exit_code, stderr_str, rss_bytes, limit_bytes): + violation_reason = "memory_limit" + + return stdout_str, stderr_str, exit_code, violation_reason diff --git a/backend/secuscan/scanners/api_scanner.py b/backend/secuscan/scanners/api_scanner.py new file mode 100644 index 000000000..d3ed5a6d6 --- /dev/null +++ b/backend/secuscan/scanners/api_scanner.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, List +from urllib.parse import urljoin + +import httpx + +from .base import BaseScanner +from ..crawler import crawl_target + + +class APIScanner(BaseScanner): + """API discovery plus lightweight REST/GraphQL assessment.""" + + COMMON_SPEC_PATHS = [ + "/openapi.json", + "/swagger.json", + "/v3/api-docs", + "/api/openapi.json", + "/swagger/v1/swagger.json", + ] + + GRAPHQL_PATHS = ["/graphql", "/api/graphql", "/query"] + HIGH_VALUE_TOKENS = ("/admin", "/internal", "/users", "/accounts", "/tokens", "/auth", "/config") + RISKY_METHODS = {"put", "patch", "delete"} + + @property + def name(self) -> str: + return "API Scanner" + + @property + def category(self) -> str: + return "API Security" + + async def run(self, target: str, inputs: Dict[str, Any]) -> Dict[str, Any]: + timeout = int(inputs.get("timeout") or 10) + extra_headers = inputs.get("__extra_headers") if isinstance(inputs.get("__extra_headers"), dict) else {} + cookies = inputs.get("__cookies") if isinstance(inputs.get("__cookies"), dict) else {} + execution_context = inputs.get("__execution_context") if isinstance(inputs.get("__execution_context"), dict) else {} + target_policy = inputs.get("__target_policy") if isinstance(inputs.get("__target_policy"), dict) else {} + + self.update_progress(0.1) + crawl = await crawl_target(target, timeout=timeout, cookies=cookies, extra_headers=extra_headers) + findings: List[Dict[str, Any]] = [] + api_hints = list(crawl.get("api_hints", [])) + endpoint_inventory: List[Dict[str, Any]] = [] + + self.update_progress(0.3) + async with httpx.AsyncClient( + follow_redirects=True, + timeout=timeout, + headers={str(k): str(v) for k, v in extra_headers.items()}, + cookies={str(k): str(v) for k, v in cookies.items()}, + verify=False, + ) as client: + for path in self.COMMON_SPEC_PATHS: + url = urljoin(target.rstrip("/") + "/", path.lstrip("/")) + document = await self._fetch_spec(client, url) + if not document: + continue + api_hints.append(url) + spec_findings, endpoints = self._analyze_spec(url, document, target) + findings.extend(spec_findings) + endpoint_inventory.extend(endpoints) + + allow_graphql_introspection = bool( + target_policy.get("allow_exploit_validation") + and execution_context.get("validation_mode") != "detect_only" + ) + graphql_findings, graphql_endpoints = await self._probe_graphql( + client, + target, + allow_introspection=allow_graphql_introspection, + ) + findings.extend(graphql_findings) + endpoint_inventory.extend(graphql_endpoints) + api_hints.extend(item["url"] for item in graphql_endpoints if item.get("url")) + + method_findings = await self._probe_api_hints(client, sorted(set(api_hints))[:30], target) + findings.extend(method_findings) + + if crawl.get("api_hints"): + findings.append( + { + "title": "API Paths Identified from Crawl Artifacts", + "category": "API Discovery", + "severity": "low", + "target": target, + "description": "The crawl discovered API-like paths, scripts, or schema references that should be included in route inventory and authorization coverage.", + "validated": True, + "validation_method": "passive_crawl", + "confidence_reason": "API-like paths were observed directly in application responses and scripts.", + "evidence": [{"type": "url", "label": "API hint", "value": item, "source": "crawl"} for item in sorted(set(crawl.get("api_hints", [])))[:10]], + "references": [], + "metadata": {"api_hint_count": len(crawl.get("api_hints", []))}, + } + ) + + unique_hints = sorted(set(api_hints)) + endpoint_inventory = self._dedupe_endpoints(endpoint_inventory) + self.update_progress(1.0) + return { + "status": "completed", + "summary": [ + f"API discovery completed for {target}.", + f"Collected {len(unique_hints)} API-related path hints and normalized {len(endpoint_inventory)} endpoint records.", + ], + "findings": findings, + "crawl": crawl, + "api_hints": unique_hints, + "endpoint_inventory": endpoint_inventory, + "rows": endpoint_inventory[:200], + } + + async def _fetch_spec(self, client: httpx.AsyncClient, url: str) -> Dict[str, Any] | None: + try: + response = await client.get(url) + except Exception: + return None + if response.status_code != 200 or "json" not in response.headers.get("content-type", "").lower(): + return None + try: + parsed = response.json() + except Exception: + return None + return {"url": url, "document": parsed, "status_code": response.status_code, "content_type": response.headers.get("content-type", "")} + + def _analyze_spec(self, url: str, document_bundle: Dict[str, Any], target: str) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + document = document_bundle.get("document") + if not isinstance(document, dict): + return [], [] + + findings: List[Dict[str, Any]] = [ + { + "title": f"API Specification Exposed at {url}", + "category": "API Discovery", + "severity": "medium", + "target": target, + "description": f"A machine-readable API specification is reachable at {url}.", + "remediation": "Ensure API documentation exposure matches the intended environment and access policy.", + "validated": True, + "validation_method": "http_fetch", + "confidence_reason": "HTTP 200 response returned a machine-readable API schema document.", + "evidence": [ + {"type": "url", "label": "Specification URL", "value": url, "source": "openapi"}, + {"type": "status_code", "label": "Status code", "value": document_bundle.get("status_code"), "source": "openapi"}, + ], + "references": [], + "metadata": {"url": url, "content_type": document_bundle.get("content_type", "")}, + } + ] + + endpoints: List[Dict[str, Any]] = [] + paths = document.get("paths", {}) + if not isinstance(paths, dict): + return findings, endpoints + + for path, operations in paths.items(): + if not isinstance(operations, dict): + continue + methods = [method.lower() for method, details in operations.items() if isinstance(details, dict)] + if not methods: + continue + endpoint_url = urljoin(target.rstrip("/") + "/", str(path).lstrip("/")) + endpoint_inventory = { + "type": "api_endpoint", + "url": endpoint_url, + "path": path, + "methods": methods, + "source": "openapi", + } + endpoints.append(endpoint_inventory) + risky = sorted(self.RISKY_METHODS.intersection(methods)) + if risky: + findings.append( + { + "title": f"High-Impact Methods Exposed on {path}", + "category": "API Exposure", + "severity": "medium", + "target": target, + "description": f"The API specification advertises state-changing methods ({', '.join(risky).upper()}) for {path}.", + "remediation": "Review authorization, CSRF protections for browser-invoked routes, and route-level policy before exposure.", + "validated": True, + "validation_method": "openapi_spec_analysis", + "confidence_reason": "The OpenAPI document explicitly lists these methods for the route.", + "evidence": [ + {"type": "endpoint", "label": "Route", "value": endpoint_url, "source": "openapi"}, + {"type": "methods", "label": "Methods", "value": ", ".join(sorted(methods)), "source": "openapi"}, + ], + "metadata": {"path": path, "methods": methods}, + } + ) + if any(token in path.lower() for token in self.HIGH_VALUE_TOKENS): + findings.append( + { + "title": f"High-Value API Route Present: {path}", + "category": "API Exposure", + "severity": "low", + "target": target, + "description": "A high-value route was enumerated from the API inventory and should receive focused authorization review.", + "validated": True, + "validation_method": "openapi_route_inventory", + "confidence_reason": "The route was listed directly in the exposed API definition.", + "evidence": [{"type": "endpoint", "label": "Route", "value": endpoint_url, "source": "openapi"}], + "metadata": {"path": path, "methods": methods}, + } + ) + return findings, endpoints + + async def _probe_graphql( + self, + client: httpx.AsyncClient, + target: str, + *, + allow_introspection: bool, + ) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + findings: List[Dict[str, Any]] = [] + endpoints: List[Dict[str, Any]] = [] + for path in self.GRAPHQL_PATHS: + url = urljoin(target.rstrip("/") + "/", path.lstrip("/")) + try: + response = await client.options(url) + except Exception: + continue + + allowed_methods = response.headers.get("allow", "") + if response.status_code == 200: + endpoints.append( + { + "type": "graphql_endpoint", + "url": url, + "path": path, + "methods": [item.strip().lower() for item in allowed_methods.split(",") if item.strip()], + "source": "graphql", + } + ) + + if "GET" in allowed_methods.upper() and "POST" in allowed_methods.upper(): + findings.append( + { + "title": f"GraphQL Endpoint Exposed at {path}", + "category": "API Exposure", + "severity": "low", + "target": target, + "description": "A GraphQL endpoint responded to method discovery and should be included in schema, authorization, and query-cost review.", + "validated": True, + "validation_method": "graphql_method_discovery", + "confidence_reason": "The endpoint responded directly to an OPTIONS request with advertised methods.", + "evidence": [ + {"type": "url", "label": "Endpoint", "value": url, "source": "graphql"}, + {"type": "methods", "label": "Allowed methods", "value": allowed_methods, "source": "graphql"}, + ], + "metadata": {"url": url}, + } + ) + + if not allow_introspection: + continue + + try: + gql_response = await client.post(url, json={"query": "{__schema{queryType{name}}}"}) + except Exception: + continue + if gql_response.status_code == 200 and "__schema" in gql_response.text: + findings.append( + { + "title": "GraphQL Introspection Enabled", + "category": "API Exposure", + "severity": "medium", + "target": target, + "description": "GraphQL introspection responded successfully and disclosed schema metadata.", + "remediation": "Restrict introspection in production or ensure the endpoint is appropriately authenticated.", + "validated": True, + "validation_method": "graphql_introspection", + "confidence_reason": "The endpoint returned GraphQL schema metadata to an introspection query under an explicitly allowed policy.", + "evidence": [ + {"type": "url", "label": "Endpoint", "value": url, "source": "graphql"}, + {"type": "status_code", "label": "Status code", "value": gql_response.status_code, "source": "graphql"}, + ], + "references": [], + "metadata": {"url": url}, + } + ) + return findings, endpoints + + async def _probe_api_hints(self, client: httpx.AsyncClient, api_hints: List[str], target: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for url in api_hints: + try: + response = await client.options(url) + except Exception: + continue + allow_header = response.headers.get("allow", "") + if not allow_header: + continue + methods = [item.strip().lower() for item in allow_header.split(",") if item.strip()] + risky = sorted(self.RISKY_METHODS.intersection(methods)) + if not risky: + continue + findings.append( + { + "title": f"State-Changing Methods Exposed on {url}", + "category": "API Exposure", + "severity": "medium" if any(token in url.lower() for token in self.HIGH_VALUE_TOKENS) else "low", + "target": target, + "description": "The endpoint advertises state-changing methods and should be reviewed for authorization and browser abuse protections.", + "validated": True, + "validation_method": "options_method_discovery", + "confidence_reason": "The endpoint responded directly with allowed methods via HTTP OPTIONS.", + "evidence": [ + {"type": "url", "label": "Endpoint", "value": url, "source": "http_options"}, + {"type": "methods", "label": "Allowed methods", "value": ", ".join(sorted(methods)), "source": "http_options"}, + ], + "metadata": {"url": url, "methods": methods}, + } + ) + return findings + + def _dedupe_endpoints(self, endpoints: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + staged: Dict[str, Dict[str, Any]] = {} + for endpoint in endpoints: + key = f"{endpoint.get('source')}::{endpoint.get('url') or endpoint.get('path')}" + current = staged.get(key) + if current is None: + staged[key] = dict(endpoint) + continue + current_methods = set(current.get("methods", [])) + current_methods.update(endpoint.get("methods", [])) + current["methods"] = sorted(current_methods) + return list(staged.values()) diff --git a/backend/secuscan/scanners/base.py b/backend/secuscan/scanners/base.py index 121fab22d..7a2d7384e 100644 --- a/backend/secuscan/scanners/base.py +++ b/backend/secuscan/scanners/base.py @@ -11,12 +11,40 @@ class BaseScanner(ABC): Each scanner orchestrates one or more CLI tools to achieve a higher-level goal. """ - def __init__(self, task_id: str, db: Any): + def __init__(self, task_id: str, db: Any, safe_mode: bool = True): self.task_id = task_id self.db = db + self.safe_mode = safe_mode self.start_time = datetime.now() self._progress = 0.0 + async def _execute_command(self, command: List[str]) -> tuple: + """Executes the command after validating egress policies at the boundary.""" + import asyncio + from ..validation import validate_command_network_egress + + ok, err = await asyncio.to_thread(validate_command_network_egress, command, self.safe_mode, self.name, self.task_id) + if not ok: + logger.error(f"Egress boundary check blocked command: {err}") + return f"Execution blocked by egress boundary check: {err}", -1 + + import asyncio.subprocess + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT + ) + try: + stdout, _ = await process.communicate() + return stdout.decode('utf-8', errors='replace'), process.returncode + except asyncio.CancelledError: + try: + process.kill() + await process.wait() + except Exception: + pass + raise + @property @abstractmethod def name(self) -> str: diff --git a/backend/secuscan/scanners/network_vulnerability_scanner.py b/backend/secuscan/scanners/network_vulnerability_scanner.py new file mode 100644 index 000000000..cc0371d5f --- /dev/null +++ b/backend/secuscan/scanners/network_vulnerability_scanner.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import asyncio +import re +import socket +import ssl +from typing import Any, Dict, List, Optional + +import httpx + +from .base import BaseScanner +from ..knowledgebase import KnowledgeBase +from ..plugins import get_plugin_manager + + +class NetworkVulnerabilityScanner(BaseScanner): + """Service fingerprinting plus local CPE/CVE correlation.""" + + HTTP_PORTS = {80, 81, 443, 591, 593, 8000, 8008, 8080, 8081, 8443, 8888} + TLS_PORTS = {443, 465, 636, 8443, 9443} + BANNER_SERVICES = {"ssh", "smtp", "pop3", "imap", "ftp"} + + @property + def name(self) -> str: + return "Network Vulnerability Scanner" + + @property + def category(self) -> str: + return "Network Security" + + async def run(self, target: str, inputs: Dict[str, Any]) -> Dict[str, Any]: + self.update_progress(0.1) + plugin_manager = get_plugin_manager() + command = plugin_manager.build_command( + "nmap", + { + "target": target, + "scan_type": "T", + "ports": inputs.get("ports", "1-1000"), + "service_detection": True, + "os_detection": bool(inputs.get("os_detection", False)), + "safe_mode": self.safe_mode, + }, + ) + if not command: + raise ValueError("Failed to build nmap service-fingerprint command") + + output, exit_code = await self._execute_command(command) + self.update_progress(0.45) + + kb = KnowledgeBase() + services = self._parse_services(output, target) + enriched_services = await self._enrich_services(services) + correlated_services = [self._apply_knowledgebase(item, kb) for item in enriched_services] + findings = self._build_findings(target, correlated_services) + self.update_progress(1.0) + return { + "status": "completed" if exit_code == 0 else "failed", + "summary": [ + f"Fingerprint scan completed for {target}.", + f"Observed {len(correlated_services)} exposed services with {sum(len(s.get('cves', [])) for s in correlated_services)} local knowledge-base matches.", + ], + "findings": findings, + "services": correlated_services, + "rows": [ + { + "host": item["host"], + "port": item["port"], + "protocol": item["protocol"], + "service": item["service"], + "product": item.get("product"), + "version": item.get("version"), + "cpe": item.get("cpe"), + "confidence": item.get("confidence"), + } + for item in correlated_services + ], + "asset_services": correlated_services, + } + + def _parse_services(self, output: str, target: str) -> List[Dict[str, Any]]: + services: List[Dict[str, Any]] = [] + current_host = target + host_pattern = re.compile(r"^Nmap scan report for (?P<host>.+)$") + service_pattern = re.compile(r"(?P<port>\d+)/(tcp|udp)\s+open\s+(?P<service>[\w\-\/]+)(?:\s+(?P<version>.*))?$") + for raw_line in output.splitlines(): + line = raw_line.strip() + host_match = host_pattern.match(line) + if host_match: + current_host = host_match.group("host").strip() + continue + + match = service_pattern.search(line) + if not match: + continue + + port = int(match.group("port")) + protocol = "tcp" if "/tcp" in line else "udp" + service = match.group("service").strip().lower() + version_text = (match.group("version") or "").strip() + product = self._extract_product(service, version_text) + version = self._extract_version(version_text) + services.append( + { + "host": current_host, + "port": port, + "protocol": protocol, + "service": service, + "product": product, + "version": version or version_text, + "banner": line, + "metadata": {"nmap_line": line, "raw_version": version_text}, + } + ) + return services + + def _extract_product(self, service: str, version_text: str) -> str: + if not version_text: + return service + tokens = version_text.split() + if not tokens: + return service + if len(tokens) >= 2 and re.search(r"\d", tokens[1]): + return tokens[0] + if len(tokens) >= 2: + return " ".join(tokens[:2]) + return tokens[0] + + def _extract_version(self, version_text: str) -> str: + match = re.search(r"\d+(?:\.\d+){0,3}", version_text or "") + return match.group(0) if match else "" + + async def _enrich_services(self, services: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + enriched: List[Dict[str, Any]] = [] + for index, service in enumerate(services, start=1): + enriched.append(await self._enrich_service(service)) + self.update_progress(0.45 + (0.35 * (index / max(1, len(services))))) + return enriched + + async def _enrich_service(self, service: Dict[str, Any]) -> Dict[str, Any]: + host = str(service.get("host") or "") + port = int(service.get("port") or 0) + service_name = str(service.get("service") or "").lower() + metadata = dict(service.get("metadata") or {}) + confidence = 0.62 + + if self._is_http_service(service_name, port): + http_probe = await self._probe_http(host, port, tls=self._uses_tls(service_name, port)) + if http_probe: + metadata["http_probe"] = http_probe + service["title"] = http_probe.get("title") + service["banner"] = http_probe.get("server") or service.get("banner") + confidence += 0.15 + + if self._uses_tls(service_name, port): + tls_probe = await asyncio.to_thread(self._probe_tls, host, port) + if tls_probe: + metadata["tls_probe"] = tls_probe + service["cert_subject"] = tls_probe.get("subject") + service["cert_sans"] = tls_probe.get("sans", []) + service["cert_expiry"] = tls_probe.get("expiry") + confidence += 0.12 + + if service_name in self.BANNER_SERVICES or port in {22, 21, 25, 110, 143}: + banner_probe = await asyncio.to_thread(self._probe_banner, host, port) + if banner_probe: + metadata["socket_probe"] = {"banner": banner_probe} + service["banner"] = banner_probe + confidence += 0.08 + + service["metadata"] = metadata + service["confidence"] = round(min(confidence, 0.96), 2) + service["service_fingerprint"] = " ".join( + str(part).strip() + for part in ( + service.get("product"), + service.get("version"), + service.get("title"), + service.get("banner"), + ) + if str(part or "").strip() + )[:300] + return service + + async def _probe_http(self, host: str, port: int, *, tls: bool) -> Optional[Dict[str, Any]]: + scheme = "https" if tls else "http" + url = f"{scheme}://{host}:{port}/" + try: + async with httpx.AsyncClient(follow_redirects=True, timeout=5, verify=False) as client: + response = await client.get(url) + except Exception: + return None + return { + "url": str(response.url), + "status_code": response.status_code, + "server": response.headers.get("server"), + "title": self._extract_title(response.text), + "content_type": response.headers.get("content-type", ""), + } + + def _probe_tls(self, host: str, port: int) -> Optional[Dict[str, Any]]: + try: + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + with socket.create_connection((host, port), timeout=5) as sock: + with context.wrap_socket(sock, server_hostname=host) as wrapped: + cert = wrapped.getpeercert() + except Exception: + return None + if not cert: + return None + subject = "/".join("=".join(pair) for chunk in cert.get("subject", []) for pair in chunk) + sans = [value for kind, value in cert.get("subjectAltName", []) if kind == "DNS"] + return { + "subject": subject, + "sans": sans, + "expiry": cert.get("notAfter"), + } + + def _probe_banner(self, host: str, port: int) -> Optional[str]: + try: + with socket.create_connection((host, port), timeout=5) as sock: + sock.settimeout(5) + data = sock.recv(512) + except Exception: + return None + banner = data.decode("utf-8", errors="replace").strip() + return banner or None + + def _apply_knowledgebase(self, service: Dict[str, Any], kb: KnowledgeBase) -> Dict[str, Any]: + service_name = str(service.get("service") or "") + product = str(service.get("product") or service_name) + version = str(service.get("version") or "") + kb_match = kb.find_vulnerabilities(service=service_name, product=product, version=version) + metadata = dict(service.get("metadata") or {}) + metadata["match_strength"] = kb_match.get("match_strength", "none") + service["metadata"] = metadata + service["cpe"] = kb_match.get("cpe") + service["cves"] = kb_match.get("cves", []) + return service + + def _is_http_service(self, service_name: str, port: int) -> bool: + return "http" in service_name or port in self.HTTP_PORTS + + def _uses_tls(self, service_name: str, port: int) -> bool: + return "https" in service_name or "ssl" in service_name or port in self.TLS_PORTS + + def _extract_title(self, html: str) -> str: + start = html.lower().find("<title>") + end = html.lower().find("") + if start == -1 or end == -1 or end <= start: + return "" + return html[start + len(""):end].strip() + + def _build_findings(self, target: str, services: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for service in services: + asset_ref = f"{service['host']}:{service['port']}/{service['protocol']}" + evidence = [ + {"type": "service_banner", "label": "Service banner", "value": service.get("banner"), "source": "nmap"}, + {"type": "port", "label": "Open port", "value": f"{service['port']}/{service['protocol']}", "source": "nmap"}, + ] + if service.get("title"): + evidence.append({"type": "http_title", "label": "HTTP title", "value": service.get("title"), "source": "http_probe"}) + if service.get("cert_subject"): + evidence.append({"type": "certificate_subject", "label": "TLS certificate", "value": service.get("cert_subject"), "source": "tls_probe"}) + + findings.append( + { + "title": f"Exposed Service: {service['service']} on {service['port']}/{service['protocol']}", + "category": "Service Exposure", + "severity": "low", + "target": target, + "description": f"Service {service['service']} is reachable on {service['port']}/{service['protocol']} and was fingerprinted with additional safe probes.", + "remediation": "Restrict exposed services to trusted networks and minimize unnecessary listeners.", + "validated": True, + "validation_method": "service_fingerprint", + "confidence_reason": "Open-port state was corroborated with banner and protocol-safe enrichment probes.", + "evidence": [item for item in evidence if item.get("value")], + "asset_refs": [asset_ref], + "service_fingerprint": service.get("service_fingerprint"), + "cpe": service.get("cpe"), + "metadata": { + "port": service["port"], + "protocol": service["protocol"], + "service": service["service"], + "product": service.get("product"), + "version": service.get("version"), + "match_strength": service.get("metadata", {}).get("match_strength"), + }, + } + ) + + match_strength = str(service.get("metadata", {}).get("match_strength") or "none") + if match_strength not in {"exact", "strong_fuzzy"}: + if service.get("cpe"): + findings.append( + { + "title": f"Candidate Component Match: {service.get('cpe')}", + "category": "Technology Fingerprint", + "severity": "info", + "target": target, + "description": f"SecuScan inferred a {match_strength} component match for {service['service']} on {service['port']}/{service['protocol']}, but the match is not strong enough to open a vulnerability by default.", + "validated": False, + "validation_method": "cpe_family_inference", + "confidence_reason": f"Only a {match_strength} CPE correlation was available from the observed service fingerprint.", + "evidence": [item for item in evidence if item.get("value")], + "asset_refs": [asset_ref], + "service_fingerprint": service.get("service_fingerprint"), + "cpe": service.get("cpe"), + "metadata": { + "port": service["port"], + "protocol": service["protocol"], + "service": service["service"], + "match_strength": match_strength, + }, + } + ) + continue + + for vuln in service.get("cves", []): + findings.append( + { + "title": f"{vuln.get('cve')}: {vuln.get('title') or 'Known vulnerability'}", + "category": "Known Vulnerability", + "severity": self.normalize_severity(str(vuln.get("severity") or "medium")), + "target": target, + "description": str(vuln.get("description") or vuln.get("title") or "Known issue matched from local knowledge base."), + "remediation": "Validate the affected version, patch to a fixed release, and reduce exposure until remediation is complete.", + "cve": vuln.get("cve"), + "cvss": vuln.get("cvss"), + "validated": False, + "validation_method": "cpe_cve_correlation", + "confidence_reason": f"Service fingerprint produced a {match_strength} CPE correlation that maps to locally indexed CVE intelligence.", + "evidence": [item for item in evidence if item.get("value")], + "asset_refs": [asset_ref], + "service_fingerprint": service.get("service_fingerprint"), + "cpe": service.get("cpe"), + "references": vuln.get("references", []), + "metadata": { + "port": service["port"], + "protocol": service["protocol"], + "service": service["service"], + "product": service.get("product"), + "version": service.get("version"), + "match_strength": match_strength, + }, + } + ) + return findings diff --git a/backend/secuscan/scanners/port_scanner.py b/backend/secuscan/scanners/port_scanner.py index 14349cbe9..98fc7d4a4 100644 --- a/backend/secuscan/scanners/port_scanner.py +++ b/backend/secuscan/scanners/port_scanner.py @@ -4,7 +4,6 @@ from .base import BaseScanner from ..plugins import get_plugin_manager - class PortScanner(BaseScanner): """ Orchestrates Nmap scanning with refined result parsing. @@ -105,25 +104,6 @@ async def run(self, target: str, inputs: Dict[str, Any]) -> Dict[str, Any]: "status": "completed" if exit_code == 0 else "failed", } - async def _execute_command(self, command: List[str]) -> tuple: - """Executes the command and returns (output, exit_code)""" - import asyncio.subprocess - process = await asyncio.create_subprocess_exec( - *command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - ) - try: - stdout, _ = await process.communicate() - return stdout.decode("utf-8", errors="replace"), process.returncode - except asyncio.CancelledError: - try: - process.kill() - await process.wait() - except Exception: - pass - raise - def _parse_nmap_output(self, output: str, target: str) -> List[Dict[str, Any]]: findings = [] port_pattern = re.compile(r"(\d+)/(tcp|udp)\s+open\s+([\w-]+)\s*(.*)") diff --git a/backend/secuscan/scanners/recon_scanner.py b/backend/secuscan/scanners/recon_scanner.py index 1fb16c0c8..1f541b67c 100644 --- a/backend/secuscan/scanners/recon_scanner.py +++ b/backend/secuscan/scanners/recon_scanner.py @@ -179,21 +179,3 @@ async def _run_dns_enum(self, target: str) -> List[Dict[str, Any]]: "metadata": {"record_type": name, "value": match.group(1).strip()} }) return findings - - async def _execute_command(self, command: List[str]) -> tuple: - import asyncio.subprocess - process = await asyncio.create_subprocess_exec( - *command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT - ) - try: - stdout, _ = await process.communicate() - return stdout.decode('utf-8', errors='replace'), process.returncode - except asyncio.CancelledError: - try: - process.kill() - await process.wait() - except Exception: - pass - raise diff --git a/backend/secuscan/scanners/web_scanner.py b/backend/secuscan/scanners/web_scanner.py index 51335f0a1..f21a63c53 100644 --- a/backend/secuscan/scanners/web_scanner.py +++ b/backend/secuscan/scanners/web_scanner.py @@ -1,18 +1,29 @@ -import asyncio +from __future__ import annotations + import json import re -from typing import Dict, Any, List +from typing import Any, Dict, List + from .base import BaseScanner +from ..crawler import crawl_target from ..plugins import get_plugin_manager -from ..config import settings -from datetime import datetime + class WebScanner(BaseScanner): """ - Orchestrates DAST scanning (Nikto, Nuclei, FFUF). - Equivalent to Pentest-Tools 'Website Scanner'. + Orchestrates a layered web assessment using passive evidence first and + external scanners as corroborating sources. """ + SECURITY_HEADERS = { + "content-security-policy": ("Content-Security-Policy", "medium"), + "strict-transport-security": ("Strict-Transport-Security", "low"), + "x-frame-options": ("X-Frame-Options", "low"), + "x-content-type-options": ("X-Content-Type-Options", "low"), + "referrer-policy": ("Referrer-Policy", "low"), + "permissions-policy": ("Permissions-Policy", "info"), + } + @property def name(self) -> str: return "Web Application Scanner" @@ -22,144 +33,530 @@ def category(self) -> str: return "Web Security" async def run(self, target: str, inputs: Dict[str, Any]) -> Dict[str, Any]: - """ - Executes web vulnerability tasks and aggregates findings based on intensity. - """ intensity = inputs.get("scan_intensity", "light") - findings = [] + findings: List[Dict[str, Any]] = [] summary = [f"Performing {intensity} web scan on {target}"] - - # 1. HTTP Inspection (Technology Fingerprinting) - self.update_progress(0.1) - tech_findings = await self._run_http_inspector(target) - findings.extend(tech_findings) - summary.append(f"Identified web technologies and headers.") - self.update_progress(0.2) - - # 2. Nuclei (Fast Template-based scanning) + + extra_headers = inputs.get("__extra_headers") if isinstance(inputs.get("__extra_headers"), dict) else {} + cookies = inputs.get("__cookies") if isinstance(inputs.get("__cookies"), dict) else {} + + self.update_progress(0.05) + crawl = await crawl_target( + target, + timeout=int(inputs.get("timeout") or 10), + cookies=cookies, + extra_headers=extra_headers, + ) + findings.extend(self._build_passive_findings(target, crawl)) + summary.append( + f"Crawler captured {len(crawl.get('pages', []))} pages, {len(crawl.get('forms', []))} forms, and {len(crawl.get('api_hints', []))} API hints." + ) self.update_progress(0.3) + nuclei_findings = await self._run_nuclei(target) findings.extend(nuclei_findings) - summary.append(f"Discovered {len(nuclei_findings)} vulnerabilities via template scanning.") - self.update_progress(0.5) + if nuclei_findings: + summary.append(f"Discovered {len(nuclei_findings)} template-based observations via Nuclei.") + self.update_progress(0.55) - # 3. Nikto (Comprehensive web server scan) - Deep only if intensity in ["deep", "custom"]: - self.update_progress(0.6) nikto_findings = await self._run_nikto(target) findings.extend(nikto_findings) - summary.append(f"Completed comprehensive web server audit.") - self.update_progress(0.8) + if nikto_findings: + summary.append("Completed normalized Nikto server checks.") + self.update_progress(0.75) - # 4. FFUF (Directory Discovery) - Deep only - if intensity in ["deep", "custom"]: - self.update_progress(0.85) dir_findings = await self._run_ffuf(target) findings.extend(dir_findings) - summary.append(f"Enumerated common paths and hidden directories.") + if dir_findings: + summary.append("Enumerated exposed paths and admin/docs surfaces.") self.update_progress(1.0) + rows = [] + for page in crawl.get("pages", [])[:100]: + rows.append({"type": "page", "url": page.get("url"), "title": page.get("title")}) + for path_hint in crawl.get("path_hints", [])[:50]: + rows.append({"type": "path_hint", **path_hint}) + self.update_progress(1.0) return { "findings": findings, "summary": summary, - "status": "completed" + "status": "completed", + "crawl": crawl, + "rows": rows[:150], } - async def _run_http_inspector(self, target: str) -> List[Dict[str, Any]]: - pm = get_plugin_manager() - cmd = pm.build_command("http_inspector", {"target": target}) - if not cmd: return [] - output, _ = await self._execute_command(cmd) - - findings = [] - if match := re.search(r"(?i)Server:\s*(.*)", output): - findings.append({ - "title": f"Web Server Disclosed: {match.group(1).strip()}", - "category": "Information Disclosure", - "severity": "info", - "target": target, - "description": f"The web server discloses its version: {match.group(1).strip()}", - "metadata": {"server": match.group(1).strip()} - }) + def _build_passive_findings(self, target: str, crawl: Dict[str, Any]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + findings.extend(self._surface_findings(target, crawl)) + findings.extend(self._header_findings(target, crawl)) + findings.extend(self._cookie_findings(target, crawl)) + findings.extend(self._form_findings(target, crawl)) + findings.extend(self._path_findings(target, crawl)) + findings.extend(self._transport_findings(target, crawl)) + findings.extend(self._cms_findings(target, crawl)) + return findings + + def _surface_findings(self, target: str, crawl: Dict[str, Any]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + forms = crawl.get("forms", []) + if forms: + findings.append( + { + "title": "Interactive Forms Discovered", + "category": "Attack Surface", + "severity": "info", + "target": target, + "description": f"The crawler discovered {len(forms)} HTML forms that should be included in auth, CSRF, and input-validation review.", + "remediation": "Review each form for authentication requirements, CSRF tokens, and server-side validation.", + "validated": True, + "validation_method": "passive_crawl", + "confidence_reason": "Forms were parsed directly from the target HTML during the crawl phase.", + "evidence": [ + {"type": "form", "label": "Form action", "value": form.get("action"), "source": "crawl"} + for form in forms[:10] + ], + "metadata": {"form_count": len(forms)}, + } + ) + api_hints = crawl.get("api_hints", []) + if api_hints: + findings.append( + { + "title": "Potential API Endpoints Discovered", + "category": "API Discovery", + "severity": "low", + "target": target, + "description": "The crawl discovered URLs or scripts that look like API, OpenAPI, Swagger, or GraphQL surfaces.", + "remediation": "Include these endpoints in authentication, authorization, and schema review coverage.", + "validated": True, + "validation_method": "passive_crawl", + "confidence_reason": "API-like paths were identified from live application responses.", + "evidence": [{"type": "url", "label": "API hint", "value": item, "source": "crawl"} for item in api_hints[:10]], + "metadata": {"api_hint_count": len(api_hints)}, + } + ) + return findings + + def _header_findings(self, target: str, crawl: Dict[str, Any]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + headers = {str(key).lower(): str(value) for key, value in (crawl.get("headers") or {}).items()} + final_url = str(crawl.get("final_url") or target) + for header_key, (label, severity) in self.SECURITY_HEADERS.items(): + value = headers.get(header_key, "") + if value: + continue + findings.append( + { + "title": f"Missing {label}", + "category": "Transport Security", + "severity": severity, + "target": target, + "description": f"The response from {final_url} did not include the {label} security header.", + "remediation": f"Set {label} with an application-appropriate policy and verify it across authenticated and unauthenticated routes.", + "validated": True, + "validation_method": "header_analysis", + "confidence_reason": "The header snapshot taken during the crawl did not include this control.", + "evidence": [ + {"type": "url", "label": "Observed URL", "value": final_url, "source": "crawl"}, + {"type": "header_snapshot", "label": "Header snapshot", "value": json.dumps(headers, sort_keys=True)[:1000], "source": "crawl"}, + ], + "metadata": {"header": label, "url": final_url}, + } + ) + + server = headers.get("server") + if server: + findings.append( + { + "title": f"Server Banner Exposed: {server}", + "category": "Information Disclosure", + "severity": "info", + "target": target, + "description": "The application exposed a server banner in the HTTP response headers.", + "validated": True, + "validation_method": "header_analysis", + "confidence_reason": "The server banner was observed directly in the HTTP response headers.", + "evidence": [{"type": "header", "label": "Server", "value": server, "source": "crawl"}], + "metadata": {"server": server}, + } + ) + return findings + + def _cookie_findings(self, target: str, crawl: Dict[str, Any]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for raw_cookie in crawl.get("set_cookie_headers", [])[:20]: + parts = [segment.strip() for segment in str(raw_cookie).split(";") if segment.strip()] + if not parts: + continue + cookie_name = parts[0].split("=", 1)[0] + lowered = {segment.lower() for segment in parts[1:]} + missing_flags = [] + if "httponly" not in lowered: + missing_flags.append("HttpOnly") + if "secure" not in lowered and str(crawl.get("scheme") or "").lower() == "https": + missing_flags.append("Secure") + if not any(segment.startswith("samesite=") for segment in lowered): + missing_flags.append("SameSite") + if not missing_flags: + continue + findings.append( + { + "title": f"Insecure Cookie Attributes on {cookie_name}", + "category": "Session Management", + "severity": "medium" if "Secure" in missing_flags else "low", + "target": target, + "description": f"The cookie {cookie_name} was observed without recommended attributes: {', '.join(missing_flags)}.", + "remediation": "Set Secure, HttpOnly, and SameSite on session-relevant cookies and verify exceptions intentionally.", + "validated": True, + "validation_method": "cookie_analysis", + "confidence_reason": "Set-Cookie headers were observed directly during the crawl session.", + "evidence": [{"type": "set_cookie", "label": "Set-Cookie", "value": raw_cookie, "source": "crawl"}], + "metadata": {"cookie_name": cookie_name, "missing_flags": missing_flags}, + } + ) + return findings + + def _form_findings(self, target: str, crawl: Dict[str, Any]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for form in crawl.get("forms", [])[:20]: + action = str(form.get("action") or form.get("page_url") or target) + if form.get("state_changing") and not form.get("has_csrf_token"): + findings.append( + { + "title": f"State-Changing Form Missing CSRF Indicators: {action}", + "category": "CSRF", + "severity": "medium", + "target": target, + "description": "A state-changing form was observed without an obvious CSRF token field.", + "remediation": "Implement per-request CSRF tokens or an equivalent anti-CSRF control and verify enforcement server-side.", + "validated": True, + "validation_method": "form_analysis", + "confidence_reason": "The form structure was observed directly and no common CSRF token field name was present.", + "evidence": [ + {"type": "form_action", "label": "Form action", "value": action, "source": "crawl"}, + {"type": "form_method", "label": "Method", "value": form.get("method"), "source": "crawl"}, + ], + "metadata": {"action": action, "method": form.get("method")}, + } + ) + if form.get("password_fields") and str(crawl.get("scheme") or "").lower() != "https": + findings.append( + { + "title": f"Credential Form Exposed over Non-HTTPS: {action}", + "category": "Authentication", + "severity": "high", + "target": target, + "description": "A form containing password inputs was observed without HTTPS protection on the final URL.", + "remediation": "Require HTTPS across all authentication flows and redirect all HTTP traffic before credential exchange.", + "validated": True, + "validation_method": "form_transport_analysis", + "confidence_reason": "Password fields were parsed from the form and the final crawl URL was not HTTPS.", + "evidence": [ + {"type": "form_action", "label": "Form action", "value": action, "source": "crawl"}, + {"type": "scheme", "label": "Observed scheme", "value": crawl.get("scheme"), "source": "crawl"}, + ], + "metadata": {"action": action}, + } + ) + return findings + + def _path_findings(self, target: str, crawl: Dict[str, Any]) -> List[Dict[str, Any]]: + grouped: Dict[str, List[str]] = {} + for item in crawl.get("path_hints", []): + if not isinstance(item, dict): + continue + grouped.setdefault(str(item.get("kind") or "path"), []).append(str(item.get("url") or "")) + + findings: List[Dict[str, Any]] = [] + labels = { + "admin": ("Administrative Paths Exposed", "medium"), + "login": ("Authentication Paths Exposed", "info"), + "debug": ("Debug or Diagnostics Paths Exposed", "medium"), + "docs": ("Documentation Paths Exposed", "low"), + } + for kind, urls in grouped.items(): + if not urls: + continue + title, severity = labels.get(kind, ("Interesting Paths Discovered", "info")) + findings.append( + { + "title": title, + "category": "Asset Discovery", + "severity": severity, + "target": target, + "description": f"The crawl or directory discovery workflow located {len(urls)} {kind} path(s) that merit focused review.", + "remediation": "Confirm these paths are expected, authenticated where appropriate, and not overexposed to untrusted users.", + "validated": True, + "validation_method": "surface_discovery", + "confidence_reason": "The paths were observed directly during crawl or path enumeration.", + "evidence": [{"type": "url", "label": f"{kind.title()} path", "value": url, "source": "crawl"} for url in urls[:10]], + "metadata": {"path_kind": kind, "count": len(urls)}, + } + ) + return findings + + def _transport_findings(self, target: str, crawl: Dict[str, Any]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + seed = str(crawl.get("seed_url") or target) + final_url = str(crawl.get("final_url") or target) + scheme = str(crawl.get("scheme") or "").lower() + redirect_chain = crawl.get("redirect_chain", []) + + if scheme != "https": + findings.append( + { + "title": "HTTPS Not Enforced", + "category": "Transport Security", + "severity": "medium", + "target": target, + "description": "The crawl finished on a non-HTTPS URL, indicating plaintext transport remains available.", + "remediation": "Redirect all HTTP traffic to HTTPS and ensure sensitive routes never serve content over plaintext transport.", + "validated": True, + "validation_method": "transport_analysis", + "confidence_reason": "The final crawl URL was observed directly and did not use HTTPS.", + "evidence": [ + {"type": "seed_url", "label": "Seed URL", "value": seed, "source": "crawl"}, + {"type": "final_url", "label": "Final URL", "value": final_url, "source": "crawl"}, + ], + "metadata": {"seed_url": seed, "final_url": final_url}, + } + ) + + if seed.startswith("http://") and final_url.startswith("https://") and not redirect_chain: + findings.append( + { + "title": "HTTPS Redirect Chain Incomplete", + "category": "Transport Security", + "severity": "low", + "target": target, + "description": "The target ended on HTTPS but no redirect history was recorded, which may indicate inconsistent redirect behavior.", + "validated": True, + "validation_method": "redirect_analysis", + "confidence_reason": "The seed and final URLs differed in transport without a captured redirect chain.", + "evidence": [ + {"type": "seed_url", "label": "Seed URL", "value": seed, "source": "crawl"}, + {"type": "final_url", "label": "Final URL", "value": final_url, "source": "crawl"}, + ], + "metadata": {"seed_url": seed, "final_url": final_url}, + } + ) + return findings + + def _cms_findings(self, target: str, crawl: Dict[str, Any]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for cms in crawl.get("cms_hints", [])[:5]: + findings.append( + { + "title": f"CMS Fingerprint Detected: {str(cms).title()}", + "category": "Technology Fingerprint", + "severity": "info", + "target": target, + "description": "The crawl artifacts contained CMS-specific indicators that can be routed into targeted component validation.", + "validated": True, + "validation_method": "cms_fingerprint", + "confidence_reason": "CMS-specific paths, meta generator tags, or static assets were observed during crawl.", + "evidence": [{"type": "cms_hint", "label": "CMS hint", "value": cms, "source": "crawl"}], + "metadata": {"cms": cms}, + } + ) return findings async def _run_nuclei(self, target: str) -> List[Dict[str, Any]]: pm = get_plugin_manager() - # Ensure we use JSON output for easier parsing if available cmd = pm.build_command("nuclei", {"target": target, "silent": True}) - if not cmd: return [] - + if not cmd: + return [] + output, _ = await self._execute_command(cmd) - findings = [] - # Nuclei result pattern: [template-id] [severity] [url] [message] + findings: List[Dict[str, Any]] = [] for line in output.splitlines(): - if match := re.match(r"\[(.*?)\] \[(.*?)\] \[(.*?)\] (.*)", line): - tid, sev, url, msg = match.groups() - findings.append({ - "title": f"Nuclei: {msg}", + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + info = item.get("info", {}) if isinstance(item.get("info"), dict) else {} + template_id = item.get("template-id") or item.get("templateID") or info.get("name") or "nuclei-template" + severity = self.normalize_severity(str(info.get("severity") or item.get("severity") or "info")) + matched = item.get("matched-at") or item.get("matched") + findings.append( + { + "title": f"Nuclei: {info.get('name') or template_id}", "category": "Vulnerability", - "severity": self.normalize_severity(sev), + "severity": severity, "target": target, - "description": f"Template {tid} detected a {sev} issue on {url}.", - "metadata": {"template": tid, "url": url} - }) + "description": str(info.get("description") or item.get("matcher-name") or f"Template {template_id} matched on the target."), + "validated": False, + "validation_method": "template_scan", + "confidence_reason": "Issue was reported by a Nuclei template and should be corroborated before remediation is prioritized.", + "evidence": [ + {"type": "template", "label": "Template", "value": template_id, "source": "nuclei"}, + {"type": "url", "label": "Matched URL", "value": matched, "source": "nuclei"}, + ], + "references": [{"source": "template", "url": ref} for ref in info.get("reference", []) if isinstance(ref, str)], + "metadata": {"template": template_id, "url": matched, "source": "nuclei"}, + } + ) + + if findings: + return findings + + for line in output.splitlines(): + if match := re.match(r"\[(.*?)\] \[(.*?)\] \[(.*?)\] (.*)", line): + template_id, severity, matched, message = match.groups() + findings.append( + { + "title": f"Nuclei: {message}", + "category": "Vulnerability", + "severity": self.normalize_severity(severity), + "target": target, + "description": f"Template {template_id} detected a {severity} issue on {matched}.", + "validated": False, + "validation_method": "template_scan", + "confidence_reason": "Issue was reported by a Nuclei template and should be corroborated before remediation is prioritized.", + "evidence": [ + {"type": "template", "label": "Template", "value": template_id, "source": "nuclei"}, + {"type": "url", "label": "Matched URL", "value": matched, "source": "nuclei"}, + ], + "metadata": {"template": template_id, "url": matched, "source": "nuclei"}, + } + ) return findings async def _run_nikto(self, target: str) -> List[Dict[str, Any]]: pm = get_plugin_manager() cmd = pm.build_command("nikto", {"target": target}) - if not cmd: return [] + if not cmd: + return [] output, _ = await self._execute_command(cmd) - - findings = [] + findings: List[Dict[str, Any]] = [] + + try: + document = json.loads(output) + except json.JSONDecodeError: + document = None + + if isinstance(document, dict): + vulnerabilities = document.get("vulnerabilities") or document.get("findings") or document.get("items") or [] + if isinstance(vulnerabilities, list): + for item in vulnerabilities: + if not isinstance(item, dict): + continue + uri = item.get("uri") or item.get("url") or item.get("path") + message = item.get("msg") or item.get("message") or item.get("description") or "Nikto observation" + findings.append( + { + "title": f"Nikto: {message}", + "category": "Web Vulnerability", + "severity": self.normalize_severity(str(item.get("severity") or "medium")), + "target": target, + "description": str(message), + "validated": False, + "validation_method": "nikto_scan", + "confidence_reason": "Observation was reported by Nikto and may require manual confirmation.", + "evidence": [ + {"type": "url", "label": "Affected URL", "value": uri, "source": "nikto"}, + {"type": "nikto_item", "label": "Nikto record", "value": json.dumps(item, sort_keys=True)[:1000], "source": "nikto"}, + ], + "metadata": {"source": "nikto", "url": uri}, + } + ) + if findings: + return findings + for line in output.splitlines(): - if "+ " in line: - findings.append({ + if "+ " not in line: + continue + message = line.replace("+ ", "").strip() + findings.append( + { "title": "Nikto Observation", "category": "Web Vulnerability", - "severity": "medium", # Nikto doesn't categorize well without -Format json + "severity": "medium", "target": target, - "description": line.replace("+ ", "").strip(), - "metadata": {"source": "nikto"} - }) + "description": message, + "validated": False, + "validation_method": "nikto_scan", + "confidence_reason": "Observation was reported by Nikto and may require manual confirmation.", + "evidence": [{"type": "nikto_line", "label": "Nikto output", "value": line.strip(), "source": "nikto"}], + "metadata": {"source": "nikto"}, + } + ) return findings async def _run_ffuf(self, target: str) -> List[Dict[str, Any]]: - # FFUF is usually quiet or complex, we'll implement it as a finding of 'Interesting Paths' pm = get_plugin_manager() - cmd = pm.build_command("dir_discovery", {"target": target}) - if not cmd: return [] + cmd = pm.build_command("dir_discovery", {"base_url": target}) + if not cmd: + return [] output, _ = await self._execute_command(cmd) - - findings = [] - # Extract 200/301 results - for match in re.finditer(r"\[Status: (\d+), Size: \d+, Words: \d+, Lines: \d+, Duration: \d+ms\]\s*\|\s*URL: (.*)", output): + findings: List[Dict[str, Any]] = [] + + try: + document = json.loads(output) + except json.JSONDecodeError: + document = None + + if isinstance(document, dict) and isinstance(document.get("results"), list): + for item in document["results"]: + if not isinstance(item, dict): + continue + url = item.get("url") + status = item.get("status") + kind = self._classify_path(str(url or "").lower()) + findings.append( + { + "title": f"Discovered Path: {url}", + "category": "Asset Discovery", + "severity": "low" if kind in {"admin", "debug", "docs"} else "info", + "target": target, + "description": f"Accessible path found during fuzzing: {url}", + "validated": True, + "validation_method": "directory_fuzzing", + "confidence_reason": "The endpoint returned an HTTP success or redirect status during path enumeration.", + "evidence": [ + {"type": "url", "label": "Path", "value": url, "source": "ffuf"}, + {"type": "status_code", "label": "Status", "value": status, "source": "ffuf"}, + ], + "metadata": {"status": status, "path_kind": kind or "generic", "source": "ffuf"}, + } + ) + if findings: + return findings + + for match in re.finditer(r"\[Status: (\d+), Size: \d+, Words: \d+, Lines: \d+, Duration: .*?\]\s*\|\s*URL: (.*)", output): status, url = match.groups() - findings.append({ - "title": f"Discovered Path: {url} (Status {status})", - "category": "Asset Discovery", - "severity": "info", - "target": target, - "description": f"Accessible path found during fuzzing: {url}", - "metadata": {"status": status} - }) + kind = self._classify_path(url.lower()) + findings.append( + { + "title": f"Discovered Path: {url} (Status {status})", + "category": "Asset Discovery", + "severity": "low" if kind in {"admin", "debug", "docs"} else "info", + "target": target, + "description": f"Accessible path found during fuzzing: {url}", + "validated": True, + "validation_method": "directory_fuzzing", + "confidence_reason": "The endpoint returned an HTTP success or redirect status during path enumeration.", + "evidence": [ + {"type": "url", "label": "Path", "value": url, "source": "ffuf"}, + {"type": "status_code", "label": "Status", "value": status, "source": "ffuf"}, + ], + "metadata": {"status": status, "path_kind": kind or "generic", "source": "ffuf"}, + } + ) return findings - async def _execute_command(self, command: List[str]) -> tuple: - import asyncio.subprocess - process = await asyncio.create_subprocess_exec( - *command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT - ) - try: - stdout, _ = await process.communicate() - return stdout.decode('utf-8', errors='replace'), process.returncode - except asyncio.CancelledError: - try: - process.kill() - await process.wait() - except Exception: - pass - raise + def _classify_path(self, value: str) -> str | None: + if any(token in value for token in ("/admin", "/wp-admin", "/administrator")): + return "admin" + if any(token in value for token in ("/debug", "/console", "/actuator")): + return "debug" + if any(token in value for token in ("/docs", "/swagger", "/openapi", "/redoc")): + return "docs" + if any(token in value for token in ("/login", "/signin", "/auth")): + return "login" + return None diff --git a/backend/secuscan/scanners/xss_validation_scanner.py b/backend/secuscan/scanners/xss_validation_scanner.py new file mode 100644 index 000000000..7e806f3ec --- /dev/null +++ b/backend/secuscan/scanners/xss_validation_scanner.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from html import escape +from typing import Any, Dict, List +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse + +import httpx + +from .base import BaseScanner + + +class XSSValidationScanner(BaseScanner): + """Bounded reflected-XSS validation with local evidence only.""" + + MARKER = "SECUSCAN_XSS_MARKER" + PAYLOAD = "<script>SECUSCAN_XSS_MARKER</script>" + + @property + def name(self) -> str: + return "XSS Validation Scanner" + + @property + def category(self) -> str: + return "Web Validation" + + async def run(self, target: str, inputs: Dict[str, Any]) -> Dict[str, Any]: + probe_url = self._build_probe_url(target) + findings: List[Dict[str, Any]] = [] + self.update_progress(0.25) + + async with httpx.AsyncClient(follow_redirects=True, timeout=int(inputs.get("timeout") or 10), verify=False) as client: + response = await client.get(probe_url) + + body = response.text + reflected_raw = self.PAYLOAD in body + reflected_marker = self.MARKER in body + escaped_payload = escape(self.PAYLOAD) in body + + if reflected_raw or reflected_marker or escaped_payload: + findings.append( + { + "title": "Reflected Input Detected During XSS Validation", + "category": "Cross-Site Scripting", + "severity": "high" if reflected_raw else "medium", + "target": target, + "description": "User-controlled input was reflected by the application during bounded XSS validation.", + "remediation": "Apply context-aware output encoding and validate untrusted parameters before reflection.", + "validated": reflected_raw, + "validation_method": "bounded_reflection_probe", + "confidence_reason": ( + "The application reflected the raw script payload." + if reflected_raw + else "The application reflected the probe marker or an escaped payload, indicating a candidate XSS sink." + ), + "evidence": [ + {"type": "url", "value": probe_url}, + {"type": "status_code", "value": response.status_code}, + {"type": "marker_reflected", "value": reflected_marker}, + {"type": "raw_payload_reflected", "value": reflected_raw}, + ], + "references": [], + "proof": body[:500], + "metadata": {"probe_url": probe_url}, + } + ) + + self.update_progress(1.0) + return { + "status": "completed", + "summary": [ + f"Reflected-XSS validation completed for {target}.", + f"Captured {len(findings)} bounded validation observations without external exfiltration.", + ], + "findings": findings, + "rows": [{"url": probe_url, "status_code": response.status_code, "reflected_raw": reflected_raw}], + } + + def _build_probe_url(self, target: str) -> str: + parsed = urlparse(target) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + if not query: + query["secuscan_probe"] = self.PAYLOAD + else: + for key in list(query.keys()): + query[key] = self.PAYLOAD + return urlunparse(parsed._replace(query=urlencode(query))) diff --git a/backend/secuscan/scanners/zap_scanner.py b/backend/secuscan/scanners/zap_scanner.py new file mode 100644 index 000000000..bd341b0d5 --- /dev/null +++ b/backend/secuscan/scanners/zap_scanner.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import re +import shutil +from typing import Any, Dict, List + +from .base import BaseScanner +from ..crawler import crawl_target + + +class ZAPScanner(BaseScanner): + """Container-oriented ZAP baseline/passive orchestration.""" + + @property + def name(self) -> str: + return "OWASP ZAP Orchestrator" + + @property + def category(self) -> str: + return "DAST" + + async def run(self, target: str, inputs: Dict[str, Any]) -> Dict[str, Any]: + timeout = int(inputs.get("timeout") or 15) + findings: List[Dict[str, Any]] = [] + + self.update_progress(0.15) + crawl = await crawl_target(target, timeout=timeout) + findings.extend(self._build_passive_findings(target, crawl)) + + raw_output = "" + if shutil.which("docker"): + self.update_progress(0.45) + command = [ + "docker", + "run", + "--rm", + "ghcr.io/zaproxy/zaproxy:stable", + "zap-baseline.py", + "-t", + target, + "-m", + str(min(timeout, 5)), + ] + raw_output, exit_code = await self._execute_command(command) + findings.extend(self._parse_zap_output(target, raw_output)) + status = "completed" if exit_code == 0 else "failed" + else: + status = "completed" + + self.update_progress(1.0) + return { + "status": status, + "summary": [ + f"DAST orchestration finished for {target}.", + "Passive crawl evidence was captured and ZAP baseline execution was attempted when Docker was available.", + ], + "findings": findings, + "crawl": crawl, + "zap_output_excerpt": raw_output[:4000], + "rows": [{"url": page.get("url"), "type": "page"} for page in crawl.get("pages", [])[:100]], + } + + def _build_passive_findings(self, target: str, crawl: Dict[str, Any]) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + forms = crawl.get("forms", []) + if forms: + findings.append( + { + "title": "Interactive Forms Discovered", + "category": "Attack Surface", + "severity": "info", + "target": target, + "description": f"The crawler discovered {len(forms)} HTML forms that should be included in authenticated and DAST coverage.", + "remediation": "Review form actions for access control, CSRF protection, and input validation coverage.", + "validated": True, + "validation_method": "passive_crawl", + "confidence_reason": "Forms were parsed directly from the target HTML surface.", + "evidence": [{"type": "form", "value": form.get("action")} for form in forms[:10]], + "references": [], + "metadata": {"form_count": len(forms)}, + } + ) + return findings + + def _parse_zap_output(self, target: str, output: str) -> List[Dict[str, Any]]: + findings: List[Dict[str, Any]] = [] + for line in output.splitlines(): + text = line.strip() + if not text: + continue + match = re.search(r"(?i)(FAIL|WARN)-NEW:\s*(.*?)\s*\[(.*?)\]", text) + if not match: + continue + kind, title, ref = match.groups() + findings.append( + { + "title": f"ZAP {kind.title()}: {title}", + "category": "DAST", + "severity": "high" if kind.upper() == "FAIL" else "medium", + "target": target, + "description": text, + "remediation": "Validate the ZAP alert, confirm scope, and remediate the affected application behavior.", + "validated": False, + "validation_method": "zap_baseline", + "confidence_reason": "The issue was reported by the ZAP baseline container output.", + "evidence": [{"type": "zap_output", "value": text}], + "references": [{"source": "ZAP", "id": ref}], + "metadata": {"alert_ref": ref}, + } + ) + return findings diff --git a/backend/secuscan/validation.py b/backend/secuscan/validation.py index 0a96c5031..0a08a0e65 100644 --- a/backend/secuscan/validation.py +++ b/backend/secuscan/validation.py @@ -70,6 +70,8 @@ def wildcard_to_net(pattern: str) -> ipaddress.IPv4Network | None: for pattern in patterns: try: allowed_net = ipaddress.ip_network(pattern, strict=False) + if net.version != allowed_net.version: + continue if net.subnet_of(allowed_net) or net.overlaps(allowed_net): return True except ValueError: @@ -349,20 +351,18 @@ def validate_url(url: str) -> Tuple[bool, str]: Returns: Tuple of (is_valid, error_message) """ - # Basic URL validation url_pattern = re.compile( - r'^https?://' # http:// or https:// - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' # domain - r'localhost|' # localhost - r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # IP - r'(?::\d+)?' # optional port + r'^https?://' + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|' + r'localhost|' + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' + r'(?::\d+)?' r'(?:/?|[/?]\S+)$', re.IGNORECASE ) if not url_pattern.match(url): return False, "Invalid URL format" - # Validate optional port range if provided port_match = re.search(r':(\d+)(?:/|\?|$)', url.split('://', 1)[1]) if port_match: port = int(port_match.group(1)) @@ -372,6 +372,44 @@ def validate_url(url: str) -> Tuple[bool, str]: return True, "" +def validate_webhook_target(url: str) -> Tuple[bool, Optional[str]]: + """ + Validate webhook URL against SSRF attacks by resolving DNS and + checking resolved IPs against blocked/private networks. + + Uses the configured SECUSCAN_NOTIFICATION_BLOCKED_IP_RANGES to determine + which IP ranges to reject. This runs independently of enforce_network_policy + so webhook SSRF protection is always active. + + Args: + url: Webhook URL to validate + + Returns: + Tuple of (is_safe, error_message) + """ + hostname = urlparse(url).hostname + if not hostname: + return False, "Webhook URL has no hostname" + + try: + addrs = socket.getaddrinfo(hostname, None) + except socket.gaierror: + return False, "Webhook URL hostname could not be resolved" + + for addr in addrs: + try: + ip = ipaddress.ip_address(addr[4][0]) + except ValueError: + continue + for blocked_cidr in settings.notification_blocked_ip_ranges: + try: + if ip in ipaddress.ip_network(blocked_cidr, strict=False): + return False, f"Webhook URL resolves to blocked address ({ip}) in range {blocked_cidr}" + except ValueError: + continue + return True, None + + def sanitize_input(value: str) -> str: """ Sanitize user input to prevent command injection. @@ -382,11 +420,18 @@ def sanitize_input(value: str) -> str: Returns: Sanitized value """ - # Remove shell metacharacters and non-printable control characters + # Convert backslashes to forward slashes to preserve path separators on Windows. + value = value.replace('\\', '/') + + # Remove shell metacharacters and non-printable control characters. dangerous_chars = [';', '|', '&', '$', '`', '(', ')', '<', '>', '\n', '\r', "'", '"', '\\', '!', '{', '}', '\t', '\x00'] for char in dangerous_chars: value = value.replace(char, '') - + + # User-controlled placeholders are passed as argv values, not through a + # shell, but leading dashes can still be interpreted as tool options. + value = value.lstrip("-") + return value.strip() @@ -428,7 +473,11 @@ def match_pattern(value: str, pattern: str) -> bool: # Task-start payload size/length validation # --------------------------------------------------------------------------- -def validate_task_start_payload(raw_body: bytes, inputs: Dict[str, Any]) -> Tuple[bool, int, str]: +def validate_task_start_payload( + raw_body: bytes, + inputs: Dict[str, Any], + execution_context: Optional[Dict[str, Any]] = None, +) -> Tuple[bool, int, str]: """ Enforce size and field-length limits on POST /task/start payloads. @@ -467,6 +516,14 @@ def validate_task_start_payload(raw_body: bytes, inputs: Dict[str, Any]) -> Tupl if not ok: return ok, status, msg + if execution_context is not None: + if not isinstance(execution_context, dict): + return False, 400, "'execution_context' must be a JSON object." + for key, value in execution_context.items(): + ok, status, msg = _check_field(f"execution_context.{key}", value) + if not ok: + return ok, status, msg + return True, 0, "" @@ -499,5 +556,182 @@ def _check_field(key: str, value: Any) -> Tuple[bool, int, str]: f"maximum allowed length of " f"{settings.task_start_max_field_length} characters.", ) + ok, status, msg = _check_field(f"{key}[{idx}]", item) + if not ok: + return ok, status, msg + + elif isinstance(value, dict): + for child_key, child_value in value.items(): + ok, status, msg = _check_field(f"{key}.{child_key}", child_value) + if not ok: + return ok, status, msg return True, 0, "" + +def is_filesystem_target(target: str) -> bool: + """Best-effort detection for path-based targets that should bypass host validation. + + Returns True only for genuine local filesystem paths: + - Unix absolute paths: /home/user/repo + - Unix relative paths: ./src, ../lib + - Home-relative paths: ~/projects + - Windows paths: C:\\Users\\repo, D:/work + + CIDR notation (e.g. 8.8.8.8/32), bare hostnames, URLs, and + domain paths all return False and are subject to validate_target(). + """ + # Unix absolute, relative, and home-relative paths + if target.startswith(("/", "./", "../", "~/")): + return True + # Windows paths: C:\\ or C:/ + if re.match(r"^[A-Za-z]:[\\/]", target): + return True + return False + +def resolve_and_validate_target(url: str) -> Tuple[bool, str]: + """Resolve a webhook URL and validate it against SSRF protections. + + Performs DNS resolution, IP range validation, and port checks + to prevent Server-Side Request Forgery attacks. + """ + try: + parsed = urlparse(url) + except Exception: + return False, "Invalid URL" + + if parsed.scheme not in ("http", "https"): + return False, f"Scheme '{parsed.scheme}' not allowed for webhooks" + + hostname = parsed.hostname + if not hostname: + return False, "URL must have a hostname" + + port = parsed.port + if port is not None and port not in settings.notification_allowed_ports: + return False, f"Port {port} not in allowed ports: {settings.notification_allowed_ports}" + + # Reject raw IP addresses in webhook URLs + try: + ipaddress.ip_address(hostname) + return False, "Raw IP addresses are not allowed in webhook URLs; use a hostname" + except ValueError: + pass + + # Resolve hostname to IPs + try: + resolved = socket.getaddrinfo(hostname, port or 443, proto=socket.IPPROTO_TCP) + except OSError: + return False, f"Could not resolve hostname: {hostname}" + + for family, _socktype, _proto, _canonname, sockaddr in resolved: + try: + ip = ipaddress.ip_address(sockaddr[0]) + except ValueError: + continue + + # Check against blocked ranges + for blocked_cidr in settings.notification_blocked_ip_ranges: + try: + blocked_net = ipaddress.ip_network(blocked_cidr, strict=False) + if ip in blocked_net: + return False, f"Resolved IP {ip} falls in blocked range {blocked_cidr}" + except ValueError: + continue + + # Check allowed ranges if configured + if settings.notification_allowed_ip_ranges: + in_allowed = False + for allowed_cidr in settings.notification_allowed_ip_ranges: + try: + allowed_net = ipaddress.ip_network(allowed_cidr, strict=False) + if ip in allowed_net: + in_allowed = True + break + except ValueError: + continue + if not in_allowed: + return False, f"Resolved IP {ip} not in allowed ranges" + + return True, "" + + +def validate_command_network_egress(command: list[str], safe_mode: bool, plugin_id: str, task_id: str) -> Tuple[bool, str]: + """ + Inspect all command arguments. If any argument represents an outbound network + destination (IP, hostname, URL), validate it against both Safe Mode and Network Policy. + """ + from .network_policy import get_policy_engine + + for arg in command: + arg_str = str(arg).strip() + if not arg_str: + continue + if arg_str.startswith("-"): + continue # Ignore flags + if is_filesystem_target(arg_str): + continue # Ignore local paths + + # Check if it looks like a URL + is_url = False + hostname = None + if "://" in arg_str: + try: + parsed = urlparse(arg_str) + if parsed.scheme in ("http", "https", "ws", "wss"): + is_url = True + hostname = parsed.hostname + except Exception: + pass + + # If it's a URL, validate the hostname. If not, check if it could be a hostname or IP. + candidate = hostname if is_url else arg_str + if not candidate: + continue + + # Clean port suffix if present (e.g. "example.com:80" or "10.0.0.1:8080") + if ":" in candidate and not candidate.startswith("["): + parts = candidate.rsplit(":", 1) + if parts[1].isdigit(): + candidate = parts[0] + + is_ip = False + try: + # Try to parse as IP/CIDR (handles single IP and subnet validation) + ipaddress.ip_network(candidate, strict=False) + is_ip = True + except ValueError: + pass + + is_host = False + if not is_ip: + # Basic hostname check (with dots and valid characters, or 'localhost') + if candidate.lower() == "localhost" or re.match( + r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)+$', + candidate + ): + is_host = True + + if is_ip or is_host: + # Validate against safe mode + is_valid, err = validate_target(candidate, safe_mode=safe_mode) + if not is_valid: + return False, f"Command argument '{arg_str}' violates safe mode: {err}" + + # Validate against network policy + if settings.enforce_network_policy: + engine = get_policy_engine() + allowed, reason, _ = engine.check_access( + dest_ip=candidate, + plugin_id=plugin_id, + task_id=task_id, + ) + if not allowed: + if settings.network_policy_failure_mode == "log_only": + import logging + logging.getLogger(__name__).warning( + f"[Log Only] Command argument '{arg_str}' network policy violation allowed: {reason}" + ) + else: + return False, f"Command argument '{arg_str}' violates network policy: {reason}" + + return True, "" diff --git a/backend/secuscan/workflows.py b/backend/secuscan/workflows.py index ad71c516d..89db2e80d 100644 --- a/backend/secuscan/workflows.py +++ b/backend/secuscan/workflows.py @@ -8,7 +8,10 @@ from typing import Any, Dict, List from .database import get_db from .config import settings +from .ratelimit import workflow_rate_limiter, rate_limiter, concurrent_limiter from .executor import executor +from .execution_context import normalize_execution_context +from .platform_resources import get_target_policy logger = logging.getLogger(__name__) class WorkflowScheduler: def __init__(self): @@ -42,7 +45,7 @@ async def tick(self): db = await get_db() rows = await db.fetchall( """ - SELECT id, name, schedule_seconds, last_run_at, steps_json + SELECT id, name, owner_id, schedule_seconds, last_run_at, steps_json FROM workflows WHERE enabled = 1 AND schedule_seconds IS NOT NULL AND schedule_seconds > 0 """ @@ -51,7 +54,16 @@ async def tick(self): for row in rows: if not self._should_run(now, row.get("last_run_at"), int(row["schedule_seconds"])): continue - await self._run_workflow(row["id"], json.loads(row.get("steps_json") or "[]")) + + wf_rate_ok, wf_rate_msg = await workflow_rate_limiter.check_workflow_rate_limit( + row["id"], settings.workflow_min_interval_seconds + ) + if not wf_rate_ok: + logger.warning("Workflow %s skipped by rate limiter: %s", row["id"], wf_rate_msg) + continue + + owner_id = row["owner_id"] + await self._run_workflow(row["id"], json.loads(row.get("steps_json") or "[]"), owner_id=owner_id) await db.execute( "UPDATE workflows SET last_run_at = datetime('now') WHERE id = ?", (row["id"],), @@ -68,27 +80,83 @@ def _should_run(self, now: datetime, last_run_at: str | None, schedule_seconds: last = last.replace(tzinfo=timezone.utc) elapsed = (now - last).total_seconds() return elapsed >= schedule_seconds - async def _run_workflow(self, workflow_id: str, steps: List[Dict[str, Any]]): + async def _run_workflow(self, workflow_id: str, steps: List[Dict[str, Any]], owner_id: str = "default"): logger.info("Running workflow %s with %d step(s)", workflow_id, len(steps)) + db = await get_db() for step in steps: plugin_id = step.get("plugin_id") inputs = step.get("inputs") or {} if not plugin_id: continue request_id = get_request_id() - safe_mode = bool(settings.safe_mode_default) + execution_context = normalize_execution_context(step.get("execution_context") or {}) + target_policy = await get_target_policy(db, owner_id, execution_context.get("target_policy_id")) + safe_mode = bool( + settings.safe_mode_default + and not (target_policy and target_policy.get("allow_public_targets")) + ) + + from .plugins import get_plugin_manager + from .validation import validate_target + from .network_policy import get_policy_engine + + plugin_manager = get_plugin_manager() + plugin = plugin_manager.get_plugin(plugin_id) + if not plugin: + logger.warning("Workflow %s: plugin %s not found, skipping step", workflow_id, plugin_id) + continue effective_inputs = dict(inputs) effective_inputs.pop("safe_mode", None) effective_inputs["safe_mode"] = safe_mode + if target := effective_inputs.get("target"): + target_str = str(target) + if plugin.category != "code": + try: + is_valid, error_msg = await asyncio.wait_for( + asyncio.to_thread(validate_target, target_str, safe_mode), + timeout=float(settings.dns_resolution_timeout_seconds), + ) + if not is_valid: + logger.warning("Workflow %s: target validation failed for step %s: %s", workflow_id, plugin_id, error_msg) + continue + except asyncio.TimeoutError: + logger.warning("Workflow %s: target validation timed out for step %s", workflow_id, plugin_id) + continue + + if settings.enforce_network_policy and target_str: + engine = get_policy_engine() + allowed, reason, _ = await asyncio.wait_for( + asyncio.to_thread(engine.check_access, dest_ip=target_str, plugin_id=plugin_id, task_id=""), + timeout=float(settings.dns_resolution_timeout_seconds), + ) + if not allowed: + logger.warning("Workflow %s: network policy denied %s: %s", workflow_id, target_str, reason) + continue + + client = f"user:{owner_id}" + max_per_hour = plugin.safety.get("rate_limit", {}).get("max_per_hour", settings.max_tasks_per_hour) if plugin else settings.max_tasks_per_hour + can_exec, rate_err = await rate_limiter.can_execute(plugin_id, max_per_hour, client_id=client) + if not can_exec: + logger.warning("Workflow %s: rate limit exceeded for %s: %s", workflow_id, plugin_id, rate_err) + continue + task_id = await executor.create_task( plugin_id, effective_inputs, safe_mode=safe_mode, preset=step.get("preset"), + execution_context=execution_context, consent_granted=True, + owner_id=owner_id, ) + can_acquire, concurrency_err = await concurrent_limiter.acquire(task_id) + if not can_acquire: + await executor.mark_task_failed(task_id, reason="Concurrency limit reached") + logger.warning("Workflow %s: concurrency limit reached for %s", workflow_id, plugin_id) + continue + async def run_task(task_id: str) -> None: set_request_id(request_id) await executor.execute_task(task_id) diff --git a/docs/AI_SUMMARY.md b/docs/AI_SUMMARY.md new file mode 100644 index 000000000..bc7c32185 --- /dev/null +++ b/docs/AI_SUMMARY.md @@ -0,0 +1,103 @@ +# AI Executive Summary — Setup & Usage Guide + +SecuScan can optionally generate a concise plain-English executive summary at the +top of HTML and PDF scan reports. The summary is produced by an LLM after a scan +completes and is aimed at non-technical stakeholders who need a quick +"what happened and what matters most?" without reading every raw finding. + +The feature is **completely opt-in**. When not configured it has zero effect — +reports generate exactly as before, no exceptions, no extra dependencies needed. + +--- + +## How It Works + +1. After a scan finishes, `ReportGenerator` calls `_get_ai_summary()` with the + list of normalised findings. +2. `generate_summary()` in `ai_summary.py` builds a prompt from **metadata only** + — severity counts, categories, and finding titles. Hostnames, IPs, URLs, and + credentials are **never** included in the prompt. +3. The LLM returns a 3–5 sentence plain-text paragraph. +4. The summary appears as a highlighted block at the top of the Executive Overview + section in both HTML and PDF reports. SARIF is left untouched. + +--- + +## Configuration + +Set these environment variables before starting the backend +(prefix them with `SECUSCAN_` as per the `Settings` class convention): + +| Variable | Required | Default | Description | +|---|---|---|---| +| `SECUSCAN_AI_SUMMARY_ENABLED` | Yes (to activate) | `false` | Set to `true` to turn the feature on. | +| `SECUSCAN_AI_SUMMARY_API_KEY` | Yes (when enabled) | _(empty)_ | API key for your LLM provider. | +| `SECUSCAN_AI_SUMMARY_BASE_URL` | No | _(empty → OpenAI)_ | Override for non-OpenAI endpoints. | +| `SECUSCAN_AI_SUMMARY_MODEL` | No | `gpt-4o-mini` | Model name. | + +### OpenAI (cloud) + +```bash +export SECUSCAN_AI_SUMMARY_ENABLED=true +export SECUSCAN_AI_SUMMARY_API_KEY=sk-...your-key... +export SECUSCAN_AI_SUMMARY_MODEL=gpt-4o-mini +``` + +### Ollama (local, free, no data leaves your machine) + +```bash +ollama pull llama3 + +export SECUSCAN_AI_SUMMARY_ENABLED=true +export SECUSCAN_AI_SUMMARY_API_KEY=ollama +export SECUSCAN_AI_SUMMARY_BASE_URL=http://localhost:11434/v1 +export SECUSCAN_AI_SUMMARY_MODEL=llama3 +``` + +### Any other OpenAI-compatible provider + +```bash +export SECUSCAN_AI_SUMMARY_ENABLED=true +export SECUSCAN_AI_SUMMARY_API_KEY=your-key +export SECUSCAN_AI_SUMMARY_BASE_URL=https://api.your-provider.com/v1 +export SECUSCAN_AI_SUMMARY_MODEL=provider-model-name +``` + +--- + +## Dependency + +`openai>=1.0.0` is already added to `backend/requirements.txt`. Install with: + +```bash +pip install -r backend/requirements.txt +``` + +--- + +## Privacy & Safety + +- Only **finding metadata** (severity, category, title) is sent to the LLM. +- Raw hostnames, IPs, URLs, and credentials are **never** included in the prompt. +- For high-sensitivity environments, use a local Ollama instance so no data + leaves your network. +- If using a cloud provider, review their data-retention policy before enabling. + +--- + +## Disabling + +Leave `SECUSCAN_AI_SUMMARY_ENABLED` unset or set it to `false`. Reports will +generate exactly as before. The `openai` package does not need to be installed. + +--- + +## Running the Tests + +```bash +# Full backend suite +./testing/test_python.sh + +# Targeted +python -m pytest testing/backend/unit/test_ai_summary.py -v +``` diff --git a/docs/API.md b/docs/API.md index 084f33676..5671c0f26 100644 --- a/docs/API.md +++ b/docs/API.md @@ -31,13 +31,16 @@ "previous": null } } +``` - - -""" +```bash # Basic pagination curl "http://localhost:8000/api/v1/tasks?page=2&per_page=10" # With filters curl "http://localhost:8000/api/v1/tasks?status=completed&plugin_id=nmap&page=1&per_page=20" -""" \ No newline at end of file +``` + +## See Also + +* [Backend Architecture](backend-architecture.md) — For a detailed overview of the backend's module structure, routing, execution engine, and scanners. diff --git a/docs/SECURE_DEPLOYMENT.md b/docs/SECURE_DEPLOYMENT.md index d26b00f6c..aefa9152f 100644 --- a/docs/SECURE_DEPLOYMENT.md +++ b/docs/SECURE_DEPLOYMENT.md @@ -142,6 +142,44 @@ Internal Database / Services --- +# Webhook Security Constraints + +SecuScan deployments may integrate with outbound webhooks for notifications, automation workflows, or external integrations. Operators should treat webhook destinations as untrusted network targets unless explicitly approved. + +## SSRF Risk + +Webhook functionality can introduce Server-Side Request Forgery (SSRF) risks if arbitrary destinations are allowed. + +Potential impacts include: + +* Access to internal-only services +* Access to cloud metadata endpoints +* Network reconnaissance of private infrastructure +* Data exfiltration through attacker-controlled endpoints + +## Recommended Restrictions + +Operators should: + +* Maintain an allowlist of approved webhook destinations +* Restrict webhook traffic to trusted HTTPS endpoints +* Block requests to localhost (`127.0.0.1`, `::1`) +* Block requests to private RFC1918 address ranges where possible +* Block access to cloud metadata services +* Avoid forwarding sensitive credentials in webhook payloads + +## Safe Operator Configuration + +When enabling webhook integrations: + +* Review all configured destinations before deployment +* Use dedicated webhook credentials where supported +* Enable outbound firewall filtering when possible +* Monitor webhook delivery failures and unexpected destinations +* Remove unused webhook configurations regularly + +Webhook integrations should be treated as external trust boundaries and reviewed during security audits. + # Deployment Profiles ## Local Development @@ -203,6 +241,15 @@ securityContext: readOnlyRootFilesystem: true ``` +### Docker Sandbox Network Isolation + +When running in Docker-sandboxed mode (`SECUSCAN_DOCKER_ENABLED=true`), SecuScan executes standard plugins inside isolated container sandboxes. + +To ensure strict network isolation and prevent lateral movement or inter-container communication (ICC): +* SecuScan uses a dedicated Docker bridge network defined by the environment variable `SECUSCAN_DOCKER_NETWORK` (defaults to `restricted`). +* If this network does not exist, the Task Executor will **automatically create it** on first use with `--opt com.docker.network.bridge.enable_icc=false` (Inter-Container Communication disabled). This ensures sandbox containers cannot talk to each other or the host's private endpoints. +* If ICC-disabled network creation fails, it will attempt a standard bridge fallback before failing with a fatal runtime error. + --- # Hardening Checklist diff --git a/docs/api-authentication.md b/docs/api-authentication.md index 75167b9be..fd1936455 100644 --- a/docs/api-authentication.md +++ b/docs/api-authentication.md @@ -82,3 +82,7 @@ header. Requests without a valid key receive `HTTP 401`. - If the backend is not yet initialised (key file missing and startup not complete), protected routes return `HTTP 503` rather than `401` to distinguish between an uninitialised service and a bad credential. +- API keys should never be transmitted to third-party webhook destinations. +- Operators should avoid embedding API credentials in webhook payloads, query parameters, or callback URLs. +- When webhook integrations are used, restrict outbound destinations to trusted services and use HTTPS for all webhook traffic. +- Webhook endpoints should be reviewed periodically to reduce SSRF exposure and accidental data disclosure risks. diff --git a/docs/backend-architecture.md b/docs/backend-architecture.md new file mode 100644 index 000000000..355d7c5dc --- /dev/null +++ b/docs/backend-architecture.md @@ -0,0 +1,217 @@ +# Backend Architecture + +## Overview +SecuScan is a local-first pentesting and vulnerability scanning toolkit. The backend is built on FastAPI, providing a modular framework for running security scans, managing target network permissions, parsing tool outputs, and generating reports. This document outlines the module architecture, standard execution pipelines, and core extension points for contributors. + +--- + +## Module Map + +| Path / Module | Responsibility | +|---|---| +| [`main.py`](../backend/secuscan/main.py) | Application entry point: bootstraps FastAPI, lifespan hooks, and mounts middleware/routers. | +| [`routes.py`](../backend/secuscan/routes.py) | Primary REST API layer implementing all standard scanner, task, finding, and reporting endpoints. | +| [`saved_views.py`](../backend/secuscan/saved_views.py) | API router for the analyst saved-views feature, mounted separately in `main.py`. | +| [`config.py`](../backend/secuscan/config.py) | Configuration settings class (`Settings`) loaded from environment variables using Pydantic. | +| [`database.py`](../backend/secuscan/database.py) | Async SQLite wrapper (`aiosqlite`) that manages schema creation, migrations, and database operations. | +| [`migrations/`](../backend/secuscan/migrations) | Numbered SQL migration files applied sequentially at database initialization. | +| [`plugins.py`](../backend/secuscan/plugins.py) | Plugin loader (`PluginManager`) handling metadata validation, checksums, and signature checks. | +| [`plugin_validator.py`](../backend/secuscan/plugin_validator.py) | Standalone validator for plugin directories, shared with CLI helper validation scripts. | +| [`models.py`](../backend/secuscan/models.py) | Canonical Pydantic schemas representing request payloads, response templates, and data entities. | +| [`executor.py`](../backend/secuscan/executor.py) | Core task execution engine orchestrating execution environments, sandboxing, and phase changes. | +| [`execution_context.py`](../backend/secuscan/execution_context.py) | Normalizes and defines the `ExecutionContext` model and validation mode rules. | +| [`parser_sandbox.py`](../backend/secuscan/parser_sandbox.py) | Subprocess wrapper running third-party plugin parsers in a sandbox to isolate crashes. | +| [`capabilities.py`](../backend/secuscan/capabilities.py) | Validates and enforces capability lists (e.g. `local_network`, `raw_socket`) for plugins. | +| [`ratelimit.py`](../backend/secuscan/ratelimit.py) | Controls concurrent task runs and enforces API endpoint rate limits. | +| [`scanners/`](../backend/secuscan/scanners) | Folder holding built-in Python-native scanner implementations subclassing `BaseScanner`. | +| [`validation.py`](../backend/secuscan/validation.py) | Guardrails validating target IPs, URLs, hostnames, ports, and command arguments. | +| [`network_policy.py`](../backend/secuscan/network_policy.py) | Admin-configurable egress engine determining permitted destination networks and IPs. | +| [`redaction.py`](../backend/secuscan/redaction.py) | Utility removing sensitive API keys, credentials, and PII from scan output prior to storage. | +| [`auth.py`](../backend/secuscan/auth.py) | Middleware-free API-key generation, verification, and file-based token configuration. | +| [`vault.py`](../backend/secuscan/vault.py) | Handles symmetric credential encryption for credentials stored inside the database. | +| [`finding_intelligence.py`](../backend/secuscan/finding_intelligence.py) | Normalizes, deduplicates, correlates findings, groups alerts, and determines assets. | +| [`risk_scoring.py`](../backend/secuscan/risk_scoring.py) | Algorithm computing finding priority scores based on severity, CVSS, and asset exposure. | +| [`knowledgebase.py`](../backend/secuscan/knowledgebase.py) | Loads static reference data for vulnerability mappings and descriptions from disk. | +| [`reporting.py`](../backend/secuscan/reporting.py) | Exporters rendering findings into HTML, PDF (xhtml2pdf), CSV, or SARIF documents. | +| [`workflows.py`](../backend/secuscan/workflows.py) | Periodically queries database for scheduled automation workflows and triggers tasks. | +| [`platform_resources.py`](../backend/secuscan/platform_resources.py) | Retrieves target, session, and credential profiles and serializes scan variables. | +| [`crawler.py`](../backend/secuscan/crawler.py) | Provides authenticated web crawling and parsing tools for web-oriented scans. | +| [`cache.py`](../backend/secuscan/cache.py) | High-performance cache wrapper abstracting in-memory or Redis key-value caching. | +| [`notification_service.py`](../backend/secuscan/notification_service.py) | Dispatches post-task events to third-party webhooks or email channels. | +| [`logging_utils.py`](../backend/secuscan/logging_utils.py) | Logging formatters generating structured, machine-parseable JSON log messages. | +| [`request_context.py`](../backend/secuscan/request_context.py) | ContextVar wrappers maintaining the current request's unique identifier (UUID). | +| [`request_middleware.py`](../backend/secuscan/request_middleware.py) | ASGI middleware assigning and injecting a unique request ID into response headers. | +| [`cli.py`](../backend/secuscan/cli.py) | Command-line interface allowing users to run scans directly from their terminals. | +| [`__init__.py`](../backend/secuscan/__init__.py) | Package initialization file indicating a Python package structure. | + +--- + +## Scan Execution Flow + +The sequence diagram below traces how a target scanning request is processed end-to-end: + +```mermaid +sequenceDiagram + autonumber + actor Operator + participant API as routes.py / main.py + participant Exec as executor.py + participant Val as validation.py + participant Sub as subprocess / Docker + participant Sandbox as parser_sandbox.py + participant Intel as finding_intelligence.py + participant DB as database.py + participant Report as reporting.py + + Operator->>API: POST /api/v1/tasks (Create Task) + API->>DB: Write Task (QUEUED) + API-->>Operator: Return task_id + + Operator->>API: POST /api/v1/tasks/{id}/start + API->>Exec: execute_task(task_id) + + rect rgb(240, 240, 240) + Note over Exec, Val: Phase: running_command + Exec->>Val: validate_target(target) / validate_command_network_egress() + Val-->>Exec: Allowed / Blocked + Exec->>Sub: Spawns scanner process (CLI or sandboxed Docker container) + Sub-->>Exec: Raw tool stdout/stderr output + end + + rect rgb(230, 240, 250) + Note over Exec, Sandbox: Phase: parsing + Exec->>Sandbox: run_parser_in_sandbox() (Executes parser.py script) + Sandbox-->>Exec: Parsed JSON findings + end + + rect rgb(220, 240, 240) + Note over Exec, Intel: Correlation & Persistence + Exec->>Intel: normalize_and_correlate_findings() / risk_scoring.py + Intel-->>Exec: Enriched findings, assets, groups & risk scores + Exec->>DB: Save findings, assets, and update task status (COMPLETED) + end + + rect rgb(250, 240, 230) + Note over Exec, Report: Phase: reporting + Exec->>Report: Generate Report (PDF, HTML, CSV, SARIF) + Report->>DB: Save report path references + end + + Exec-->>API: Task Finished +``` + +--- + +## Layer-by-Layer Reference + +### Application Bootstrap (`main.py`) +`main.py` is the application entry point. It creates the FastAPI application instance, configures global structured logging, and registers CORS configuration alongside the custom `RequestIDMiddleware`. The database (`init_db`), global cache (`init_cache`), plugins (`init_plugins`), and Docker network configurations are initialized within an `@asynccontextmanager` lifecycle hook. Finally, it registers API routes and exposes the `main()` uvicorn entry point. + +### Configuration (`config.py`) +`config.py` manages configuration options through a Pydantic-powered `Settings` class that maps environment variables with the `SECUSCAN_` prefix. Key configuration keys include file directory paths, database settings, Docker security properties, safe-mode targets, CORS policies, rate limits, and cryptographic keys. Contributors altering runtime properties must register settings within the `Settings` class definition in `config.py` rather than hardcoding credentials or configurations inside modules. + +### API Layer (`routes.py`, `saved_views.py`) +`routes.py` manages the primary REST API endpoints of the system. It groups endpoints under `/api/v1` for tasks, findings, plugins, reports, workflows, and policies, importing models from `models.py` for payload validation. +`saved_views.py` contains endpoints specifically for saving filter criteria and results, keeping the primary API route codebase isolated. +> [!IMPORTANT] +> `routes.py` is a single file layer containing the entire standard API endpoints surface (~93 KB). Keep new endpoint definitions here, and do not create nested route directories unless major refactorings are approved. + +### Data Layer (`database.py`, `migrations/`) +`database.py` defines the async database interface leveraging SQLite via `aiosqlite`. It contains helper wrappers `fetchall`, `fetchone`, and `execute`, alongside database connection and auditing triggers. Schema structure and versions are handled under `backend/secuscan/migrations` as incremental, numbered SQL scripts applied sequentially at database initialization. Contributors changing DB queries or schemas must write a new SQL migration file inside the folder and let the app boot apply it. + +### Plugin System (`plugins.py`, `plugin_validator.py`) +`plugins.py` defines the `PluginManager` that handles loading third-party integration descriptors (`metadata.json`) from individual directories. It enforces integrity checks, comparing SHA-256 checksums and digital signatures to verify that files have not been modified post-startup. +`plugin_validator.py` executes standalone validation rules verifying that plugin configurations contain the required properties, field schema structures, and valid engines. + +### Task Execution Engine (`executor.py`, `execution_context.py`, `parser_sandbox.py`, `capabilities.py`, `ratelimit.py`) +`executor.py` coordinates task execution state and triggers scans according to target configuration. It enforces safe target constraints, applies sandboxed Docker memory/CPU quotas, streams scan stdout, and invokes `parser_sandbox.py` to parse results safely. +`parser_sandbox.py` isolates third-party parsing scripts inside separate Python subprocesses to isolate parsing crashes. +`capabilities.py` tracks plugin capabilities like `local_network` or `raw_socket`, rejecting scans that request unauthorized capabilities. +`ratelimit.py` provides rate-limiting rules and restricts the number of concurrent scans based on configuration. +> [!IMPORTANT] +> `executor.py` handles the entire core task execution pipeline (~73 KB). Avoid adding cross-cutting feature logic directly inside `executor.py`; instead, write a helper module and import it. +> [!WARNING] +> Do not bypass the subprocess isolation in `parser_sandbox.py` for performance tuning; it is crucial to protect the engine against malicious or crashing parsers. + +### Native Scanners (`scanners/`) +Built-in scanners reside inside the `scanners/` subdirectory and subclass `BaseScanner` (`scanners/base.py`). Each built-in scanner orchestrates local binary scans (e.g. nmap, zap, or nuclei) to capture outputs directly through custom subprocess flows. +The classes (e.g. `WebScanner`, `PortScanner`, `APIScanner`) utilize the `_execute_command` interface which performs egress checking on command parameters at execution boundaries. + +### Validation & Security (`validation.py`, `network_policy.py`, `redaction.py`, `auth.py`, `vault.py`) +This security layer validates inputs, checks permissions, and guards data. +`validation.py` enforces target hostnames, loopbacks, DNS rebind checks, and command parameters formats. +`network_policy.py` matches targets against allowed/denied CIDR ranges and networks. +`redaction.py` masks credit cards, private keys, and API tokens within reports. +`auth.py` handles authentication keys, while `vault.py` encrypts DB credentials symmetrically using the vault key. + +### Findings Intelligence & Risk (`finding_intelligence.py`, `risk_scoring.py`, `knowledgebase.py`) +`finding_intelligence.py` correlates findings and builds a unified view of asset vulnerabilities. +`risk_scoring.py` computes CVSS/asset risk scores dynamically. +`knowledgebase.py` maps findings against vulnerability reference information on disk. + +### Reporting (`reporting.py`) +`reporting.py` manages report rendering, taking tasks results and outputting PDF documents, static HTML formats, spreadsheet CSV formats, or standard SARIF output. + +### Workflow Automation (`workflows.py`) +`workflows.py` schedules recurring scanning steps and monitors tasks. +> [!WARNING] +> The `WorkflowScheduler` ticks continuously every 5 seconds without utilizing a task queue. If the steps of a workflow take longer than its configured `schedule_seconds`, the scheduler will trigger a duplicate execution in the next tick. Contributors modifying this scheduler should account for this behavior. + +### Infrastructure Utilities +Utilities manage caching (`cache.py`), system logging formats (`logging_utils.py`), tracking request IDs (`request_context.py`, `request_middleware.py`), Typer-based command lines (`cli.py`), and notification rule processing (`notification_service.py`). + +--- + +## Key Data Models + +### Scan Execution Lifecycle +The execution state is controlled by two distinct fields in the database `tasks` table: +1. **`TaskStatus`** (defines global state): `queued` -> `running` -> `completed` / `failed` / `cancelled`. +2. **`ScanPhase`** (defines granular execution step): `queued` -> `running_command` -> `parsing` -> `reporting` -> `finished`. + +### Finding Properties +`Finding` fields normalize security scanner outputs inside the database: +- `id` / `finding_group_id` / `asset_id`: unique identifiers mapping details. +- `title` / `description` / `remediation`: text descriptions. +- `severity` / `risk_score` / `risk_factors`: severity evaluation. +- `proof` / `evidence`: output and validation artifacts. +- `cvss` / `cve` / `cpe`: industry vulnerability metrics. + +### ExecutionContext +Configures policy constraints for scans: +- `target_policy_id`: links scanning options. +- `scan_profile`: scan speed/depth. +- `validation_mode`: how far verification proceeds (`detect_only`, `proof`, `controlled_extract`). +- `evidence_level`: severity evidence retention levels (`minimal`, `standard`, `full`). + +--- + +## Extension Points + +### 1. Adding a New Native Scanner +Native scanners are Python classes that implement scan logic directly: +1. Subclass `BaseScanner` inside `backend/secuscan/scanners/` (e.g. `my_scanner.py`). +2. Implement required properties `name`, `category`, and async method `run(target, inputs)`. +3. Call `_execute_command(command)` for CLI tools to ensure network egress checks run. +4. Import and register your scanner in the `MODULAR_SCANNERS` mapping in `backend/secuscan/executor.py`. + +### 2. Adding a New Third-Party Plugin +Plugins integrate third-party tools via descriptors: +1. Create a subdirectory under the repository root `plugins/` (e.g. `plugins/my_tool/`). +2. Write a `metadata.json` containing metadata, input fields, and output structures. +3. Write a custom `parser.py` parsing the output to a JSON array of `Finding` models. +4. Run `python scripts/refresh_plugin_checksum.py --plugin <plugin_id>` to generate/verify checksums. + +### 3. Adding a New API Route +To extend REST paths: +1. Define a request/response Pydantic schema in `backend/secuscan/models.py`. +2. Add an endpoint method in `backend/secuscan/routes.py` with the appropriate path. +3. Fetch connections asynchronously via `get_db()` or trigger tasks via `executor.execute_task()`. + +--- + +## Related Docs +* [Contributor Guide](../CONTRIBUTING.md) — Dev environment setup and tests layout. +* [API Specification](API.md) — API endpoints schema layout. +* [Plugin Testing and Validation](plugin-validation.md) — Signature validation and metadata schema instructions. +* [Plugin Contribution Guide](../PLUGINS.md) — Writing parsers and plugin lifecycle specifications. diff --git a/docs/dependency_audit_operator_guide.md b/docs/dependency_audit_operator_guide.md new file mode 100644 index 000000000..5c0f70c82 --- /dev/null +++ b/docs/dependency_audit_operator_guide.md @@ -0,0 +1,91 @@ +# Dependency Vulnerability Audit Operator Guide + +This guide describes how the dependency vulnerability audit system functions, the format of policy exception definitions, and how to run auditing/verification checks locally. + +--- + +## 1. Exception Configuration Format + +Vulnerability exceptions are maintained in the root directory under [.audit-config.yaml](../.audit-config.yaml). + +To document a new exception (to temporarily allow a dependency vulnerability that blocks deployment in CI), add an entry under the `exceptions` block using the following format: + +```yaml +exceptions: + CVE-2026-99999: + package: vulnerable-library + severity: high + reason: | + The vulnerability requires usage of a specific API endpoint that is disabled in our environment. + We are tracking the patch and plan to upgrade by the target date. + expires_at: 2026-09-30 + approved_by: security-team + approval_date: 2026-06-01 + ticket: https://github.com/Rakshak05/SecuScan/issues/211 +``` + +### Exception Schema Fields + +| Field Name | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **Vulnerability Key** *(e.g. `CVE-2026-99999`)* | String | Yes | The primary vulnerability identifier (CVE ID, GHSA ID, or package name) used for exception matching. | +| `package` | String | Yes | Name of the package containing the vulnerability. | +| `severity` | String | No | The severity level (`critical`, `high`, `medium`, `low`) of the vulnerability. | +| `reason` | String | Yes | Clear business and technical justification for why this vulnerability does not pose an immediate threat or why the risk is accepted. | +| `expires_at` | String (ISO-8601) | Yes | The expiry date of the exception (`YYYY-MM-DD`). In CI, expired exceptions will automatically fail the build unless `enforce_expiry` is set to `false`. | +| `approved_by` | String | Yes | The individual or team that reviewed and approved the exception. | +| `approval_date` | String (ISO-8601) | No | Date when the exception was approved. | +| `ticket` | String | No | URL to a tracking ticket, issue, or pull request. | + +--- + +## 2. Local Reproduction Commands + +You can run the audit tools locally to verify dependency status and validate configuration files. + +### Backend (Python/pip dependencies) + +1. **Install requirements and developer dependencies**: + ```bash + pip install -r backend/requirements.txt -r backend/requirements-dev.txt + ``` + +2. **Run `pip-audit` to generate the raw report**: + ```bash + pip-audit -r backend/requirements.txt --desc --format json > backend/pip-audit-report.json + ``` + *(Note: Add `--include-dev` if you wish to run audits against development dependencies).* + +3. **Verify results against configuration**: + ```bash + python scripts/check_pip_audit.py \ + --report backend/pip-audit-report.json \ + --config .audit-config.yaml + ``` + +### Frontend (npm dependencies) + +1. **Install requirements**: + ```bash + cd frontend + npm ci + ``` + +2. **Run `npm audit` to generate the JSON report**: + ```bash + npm audit --json > npm-audit-report.json + ``` + +3. **Verify results against configuration**: + ```bash + python ../scripts/check_npm_audit.py \ + --report npm-audit-report.json \ + --config ../.audit-config.yaml + ``` + +### Generating Software Bill of Materials (SBOM) + +To generate a CycloneDX 1.4 compatible SBOM containing all frontend and backend dependencies, run: +```bash +python scripts/generate_sbom.py --output sbom.json --include-dev +``` diff --git a/docs/incident-response-runbook.md b/docs/incident-response-runbook.md index 8f449bc5d..bfdf8898e 100644 --- a/docs/incident-response-runbook.md +++ b/docs/incident-response-runbook.md @@ -15,6 +15,42 @@ 4. **Audit** which reports and scans ran during the exposure window 5. **Notify** affected users if credentials were accessed +### Secret Rotation Dry-Run Checklist + +Before rotating production vault keys, perform a dry-run exercise to validate the process and reduce the risk of service disruption. + +#### Preparation + +- Identify all services, scripts, and automation that depend on `SECUSCAN_VAULT_KEY` +- Confirm access to deployment environments and secret management systems +- Create a temporary non-production vault key for testing +- Ensure recent backups of encrypted vault data are available +- Notify relevant team members about the dry-run window + +#### Dry-Run Procedure + +1. Generate a temporary replacement vault key in a staging or test environment +2. Update the environment configuration to use the temporary key +3. Re-encrypt a representative set of stored credentials +4. Restart affected services and verify normal startup behavior +5. Execute common workflows that require vault access +6. Review logs for encryption, decryption, or authentication errors +7. Document any manual steps, delays, or unexpected issues encountered + +#### Validation + +- Vault secrets can be encrypted and decrypted successfully +- No application startup failures occur after key replacement +- Existing integrations continue to function correctly +- Audit logs accurately record vault access events +- Recovery steps are documented and tested + +#### Rollback Readiness + +- Retain the previous key until validation is complete +- Verify that the original configuration can be restored quickly +- Confirm that backup data can be recovered if required + ### Verification ```bash diff --git a/docs/plugin-validation.md b/docs/plugin-validation.md index 49b8de384..63b5c5ba8 100644 --- a/docs/plugin-validation.md +++ b/docs/plugin-validation.md @@ -150,4 +150,26 @@ Existing plugins using a raw `pattern` continue to work without changes: ## Backwards compatibility -Plugins that already define `validation.pattern` (without `validation_type`) continue to work exactly as before. No migration is required. \ No newline at end of file +Plugins that already define `validation.pattern` (without `validation_type`) continue to work exactly as before. No migration is required. + +--- + +## Updating plugin checksums + +When changing plugin metadata or parser behavior, refresh the stored checksum so metadata validation stays in sync. + +Typical changes that require a checksum refresh include: + +- Updating a plugin `metadata.json` file +- Changing parser expectations or capabilities +- Modifying plugin fields or defaults + +After making changes, run: + +```bash +python scripts/refresh_plugin_checksum.py --plugin <plugin_name> +``` + +Then verify that the `checksum` field inside the plugin metadata has been updated and commit the resulting change together with the documentation update. + +This keeps parser and metadata expectations aligned and prevents checksum validation failures in CI. diff --git a/docs/plugins/crawl-discovery-family.md b/docs/plugins/crawl-discovery-family.md new file mode 100644 index 000000000..c0fe643c8 --- /dev/null +++ b/docs/plugins/crawl-discovery-family.md @@ -0,0 +1,20 @@ +# Crawl and Discovery Plugin Family + +The crawl/discovery plugins overlap because they are all Katana-backed URL discovery workflows. Their catalog descriptions are intentionally distinct so users can choose the correct crawl mode. + +| Plugin | Engine | Main purpose | Key execution difference | Recommended use | +| ------------- | -------- | -------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------- | +| `katana` | `katana` | Baseline URL and route discovery | Runs Katana with minimal flags: `katana -u {target} -silent` | Use for lightweight discovery when defaults are enough. | +| `crawler` | `katana` | Recursive link discovery | Adds configurable depth: `-depth {depth:2}` | Use when controlled recursive crawling is needed. | +| `spider` | `katana` | JavaScript-aware crawling | Adds JavaScript crawling and depth: `-jc -depth {depth:3}` | Use for modern apps where client-side routes may expose more URLs. | +| `sitemap_gen` | `katana` | Sitemap-style URL inventory | Uses a deeper default crawl: `-depth {depth:4}` | Use when the goal is a broader URL inventory rather than a quick crawl. | + +## Boundary Notes + +* These plugins were not renamed or merged in this change. +* All four plugins remain separate because they expose different crawl defaults or flags. +* `sitemap_gen` does not generate XML output. It produces discovered URL results suitable for sitemap-style inventory. +* `katana` is the baseline wrapper. +* `crawler` is the depth-controlled recursive crawler. +* `spider` is the JavaScript-aware crawler. +* `sitemap_gen` is the deeper inventory-oriented crawl. diff --git a/docs/windows_contributor_guide.md b/docs/windows_contributor_guide.md index a5c268fef..f41ac02ba 100644 --- a/docs/windows_contributor_guide.md +++ b/docs/windows_contributor_guide.md @@ -27,6 +27,39 @@ git --version --- +## WSL Guidance + +Windows contributors can use PowerShell, Windows Terminal, or Git Bash for most +contribution workflows. Windows Subsystem for Linux (WSL) is not required for +general development. + +### WSL Is Optional + +WSL is optional for: + +* Documentation updates +* Frontend development +* Backend development using the Windows Python installation +* Running Git commands +* Opening pull requests + +### WSL Is Recommended + +WSL may provide a smoother experience when: + +* Running Linux-focused shell workflows +* Using Bash scripts extensively +* Working with Docker Desktop and WSL2 integration +* Reproducing issues that occur only in Linux environments + +### WSL Is Out Of Scope + +This guide focuses on native Windows development tools. WSL-specific setup, +Linux package management, and Linux distribution configuration are not covered +here. + +--- + ## Clone the Repository ```powershell @@ -34,6 +67,14 @@ git clone https://github.com/utksh1/SecuScan.git cd SecuScan ``` +If you are contributing from a fork, add the main repository as `upstream` so +you can rebase on the latest `main` before opening a pull request: + +```powershell +git remote add upstream https://github.com/utksh1/SecuScan.git +git fetch upstream +``` + --- ## Backend Setup @@ -87,6 +128,14 @@ Swagger documentation: http://127.0.0.1:8000/docs ``` +If you prefer the repository helper scripts, run them from Git Bash instead of +PowerShell because they are shell scripts: + +```bash +./setup.sh +./start.sh +``` + --- ## Frontend Setup @@ -117,6 +166,35 @@ http://127.0.0.1:5173 --- +## Backend Test Workflow + +Use the repository test script to create the isolated `venv_tests` environment +and install test dependencies: + +```bash +./testing/test_python.sh +``` + +After the first run, you can execute a single backend test file directly. + +#### PowerShell + +```powershell +.\venv_tests\Scripts\Activate.ps1 +python -m pytest testing/backend/test_task_pagination.py -v +deactivate +``` + +#### Git Bash + +```bash +source venv_tests/Scripts/activate +python -m pytest testing/backend/test_task_pagination.py -v +deactivate +``` + +--- + ## Docker Setup (Optional) Install Docker Desktop for Windows: @@ -141,6 +219,9 @@ Run the project stack: docker compose up --build ``` +If `docker compose` fails even though Docker Desktop is installed, open Docker +Desktop first and wait until the engine reports it is running. + --- ## Common Windows Troubleshooting @@ -208,6 +289,52 @@ Reinstall dependencies: npm install ``` +If Vite still fails to start after dependency updates, remove the existing +install and reinstall from scratch: + +```powershell +Remove-Item -Recurse -Force node_modules +Remove-Item package-lock.json +npm install +``` + +--- + +### Git Bash vs PowerShell Command Mismatch + +Use Git Bash for commands that rely on Unix shell syntax: + +```bash +source venv/Scripts/activate +./testing/test_python.sh +``` + +Use PowerShell for Windows-native commands: + +```powershell +.\venv\Scripts\Activate.ps1 +``` + +--- + +### Port Already In Use + +If the backend or frontend fails to start because the port is already in use, +find the conflicting process and stop it. + +Check which process is using the port: + +```powershell +netstat -ano | findstr :8000 +netstat -ano | findstr :5173 +``` + +Stop the process by PID: + +```powershell +taskkill /PID <PID> /F +``` + --- ## Git Workflow Basics @@ -252,6 +379,12 @@ Update your branch with the latest upstream changes: git pull --rebase upstream main ``` +If `upstream` does not exist yet, confirm your remotes first: + +```powershell +git remote -v +``` + If conflicts appear: 1. Open conflicted files diff --git a/docs/workflow-scheduler.md b/docs/workflow-scheduler.md new file mode 100644 index 000000000..2b99e3ca5 --- /dev/null +++ b/docs/workflow-scheduler.md @@ -0,0 +1,250 @@ +# Workflow Scheduler + +The workflow scheduler is the component that runs scheduled, multi-step scans +on a timer. It is implemented by `backend/secuscan/workflows.py` and exposed +as the module-level singleton `WorkflowScheduler` (`scheduler`). + +This document describes the scheduler's lifecycle, the order of checks +performed for each step, the operator-tunable environment variables, and a +short troubleshooting checklist. + +## Lifecycle + +The scheduler runs an asyncio background loop that wakes up every +`asyncio.sleep(5)` seconds and calls `tick()`. The loop is started and +stopped by `start()` and `stop()` on the `WorkflowScheduler` instance. + +```python +from backend.secuscan.workflows import scheduler + +await scheduler.start() # idempotent; safe to call multiple times +... +await scheduler.stop() # cancels the running task +``` + +`start()` is a no-op if the background task is already running. `stop()` +cancels the running task and awaits it. If the task raises `CancelledError` +during shutdown it is swallowed. + +## Tick logic + +Each `tick()` performs the following actions: + +1. Query the `workflows` table for rows where + `enabled = 1 AND schedule_seconds IS NOT NULL AND schedule_seconds > 0`. +2. For each row, call `_should_run(now, last_run_at, schedule_seconds)` to + decide whether the schedule interval has elapsed since the last run. +3. If yes, call `workflow_rate_limiter.check_workflow_rate_limit(...)` to + enforce the per-workflow minimum interval (see below). +4. If allowed, call `_run_workflow(workflow_id, steps)` which processes the + workflow's steps in order. +5. After the workflow has finished, update `workflows.last_run_at = + datetime('now')`. + +### `_should_run` semantics + +`_should_run` accepts the workflow's `last_run_at` string and a +`schedule_seconds` integer. It returns `True` when: + +- `last_run_at` is `None` or empty (first-ever run), or +- the elapsed time since `last_run_at` is greater than or equal to + `schedule_seconds`. + +The function tolerates SQLite's `datetime('now')` output format +(`"2026-05-25 08:02:28"`) which lacks a timezone suffix. Naive datetimes +are treated as UTC. + +## Per-step execution + +Inside `_run_workflow`, each step is processed in declaration order. The +order of checks per step is: + +1. **Workflow rate limit** — `_check_workflow_rate_limit(workflow_id, + settings.workflow_min_interval_seconds)`. If the workflow has run more + recently than the configured minimum interval, the step is skipped and + a warning is logged. Default interval: 60 seconds. + +2. **Target validation** — If the step supplies a `target` input, the + scheduler calls `validate_target(target, safe_mode)` (in a worker thread + bounded by `settings.dns_resolution_timeout_seconds`). Invalid targets + are skipped with a warning. + +3. **Network policy** — If `settings.enforce_network_policy` is `True`, + the resolved target is checked against the `NetworkPolicyEngine`. + Violations cause the step to be skipped. + +4. **Plugin rate limit** — `rate_limiter.can_execute(plugin_id, max_per_hour, + client_id=f"user:{DEFAULT_OWNER_ID}")` where `max_per_hour` is read from + the plugin metadata's `safety.rate_limit.max_per_hour` and falls back to + `settings.max_tasks_per_hour`. The default is 50 tasks per hour. + +5. **Concurrency limit** — `concurrent_limiter.acquire(task_id)` enforces + the maximum number of tasks running simultaneously. If the limit is + reached, the task is marked as failed with the reason + `"Concurrency limit reached"`. + +6. **Task creation and execution** — `executor.create_task(...)` is called + to persist the task, then `asyncio.create_task(executor.execute_task(...))` + is launched in the background. + +Step-level errors (rate limit, validation, network policy) are logged as +warnings and the workflow continues with the next step. The workflow itself +is only aborted when `create_task` or `execute_task` raises. + +## Configuration + +| Environment variable | Default | Description | +| --- | --- | --- | +| `SECUSCAN_WORKFLOW_MIN_INTERVAL_SECONDS` | 60 | Minimum seconds between two consecutive runs of the same workflow. | +| `SECUSCAN_MAX_TASKS_PER_HOUR` | 50 | Default per-plugin hourly rate limit when a plugin does not declare its own. | +| `SECUSCAN_MAX_CONCURRENT_TASKS` | 3 | Maximum number of tasks that may be running simultaneously across the whole backend. | +| `SECUSCAN_ENFORCE_NETWORK_POLICY` | false | If `true`, every workflow step's target is checked against the `NetworkPolicyEngine` before being scheduled. | +| `SECUSCAN_DNS_RESOLUTION_TIMEOUT_SECONDS` | 5 | Maximum wall-clock seconds a target validation / network policy check may block on DNS resolution. | +| `SECUSCAN_SAFE_MODE_DEFAULT` | true | Default safe mode setting for workflow steps that do not supply their own. | + +All settings are read at scheduler-tick time, so changes to the environment +take effect on the next tick (≤ 5 seconds). + +## Workflow row format + +Workflows live in the `workflows` table: + +| Column | Type | Description | +| --- | --- | --- | +| `id` | TEXT PRIMARY KEY | Workflow identifier (UUID). | +| `name` | TEXT | Human-readable workflow name. | +| `enabled` | INTEGER (0/1) | Whether the workflow is eligible for the tick loop. | +| `schedule_seconds` | INTEGER NULL | Interval in seconds between runs; `NULL` or `0` disables scheduled runs. | +| `last_run_at` | TEXT NULL | SQLite `datetime('now')` string of the last run. | +| `steps_json` | TEXT NULL | JSON array of step objects (see below). | + +### Step object + +Each entry in `steps_json` is a dict with the following keys: + +```json +{ + "plugin_id": "nmap", + "inputs": { + "target": "10.0.0.1", + "ports": "1-1000" + }, + "execution_context": { + "scan_profile": "standard", + "validation_mode": "detect_only", + "evidence_level": "standard" + }, + "preset": "standard" +} +``` + +`plugin_id` is required. `inputs`, `execution_context`, and `preset` are +optional. `safe_mode` in `inputs` is overwritten by the scheduler with the +value computed from the workflow's target policy (or the global default). + +## Troubleshooting + +**The workflow never runs.** + +- Check that `enabled = 1` and `schedule_seconds > 0` in the `workflows` + row. +- Check the application logs for a "Workflow scheduler started" message — + if it is missing, `await scheduler.start()` was never called. +- Check the application logs for "Workflow scheduler tick failed" — a DB + error in the tick loop is logged and the loop continues, but no + workflows will be processed until the underlying error is fixed. + +**The workflow runs on every tick.** + +- `_should_run` returns `True` when `last_run_at` is `None`. The first + tick after a row is inserted will always schedule the workflow. Verify + that the subsequent UPDATE statement is succeeding — check the DB for + a recent `last_run_at`. +- If `schedule_seconds` is `1` and the workflow takes longer than 1 second + to run, the next tick will see `last_run_at` older than `schedule_seconds` + and schedule it again. Use a larger `schedule_seconds` value or rely on + the per-workflow rate limit (see below). + +**The workflow is skipped with "rate limited".** + +- The per-workflow rate limit is enforced by + `workflow_rate_limiter.check_workflow_rate_limit(workflow_id, + settings.workflow_min_interval_seconds)`. Increase + `SECUSCAN_WORKFLOW_MIN_INTERVAL_SECONDS` to allow more frequent runs. + +**The workflow is skipped with "target validation failed".** + +- The step's target did not pass `validate_target`. In safe mode, the + target must resolve to a private IP (RFC 1918) or a network in + `SECUSCAN_ALLOWED_NETWORKS`. Public IPs and CIDR ranges are blocked. + +**The task fails with "Concurrency limit reached".** + +- The `concurrent_limiter` is shared with the user-facing + `/api/v1/task/start` endpoint. Increase + `SECUSCAN_MAX_CONCURRENT_TASKS` or stagger workflow steps to avoid + running too many tasks at once. + +**The task fails with "rate limit exceeded for <plugin>".** + +- The plugin's per-hour quota is exhausted. Either wait for the hour to + roll over, or raise the plugin's `safety.rate_limit.max_per_hour` in + its `metadata.json`. + +## Example + +A minimal workflow that scans `10.0.0.1` with `nmap` every hour: + +```python +import asyncio +import json +import uuid + +from backend.secuscan.database import get_db +from backend.secuscan.workflows import scheduler + + +async def seed_workflow(): + db = await get_db() + workflow_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO workflows ( + id, name, enabled, schedule_seconds, steps_json + ) VALUES (?, ?, ?, ?, ?) + """, + ( + workflow_id, + "Hourly internal nmap scan", + 1, + 3600, + json.dumps([ + { + "plugin_id": "nmap", + "inputs": {"target": "10.0.0.1", "ports": "22,80,443"}, + "execution_context": { + "scan_profile": "standard", + "validation_mode": "detect_only", + "evidence_level": "standard", + }, + } + ]), + ), + ) + return workflow_id + + +async def main(): + workflow_id = await seed_workflow() + print(f"Created workflow {workflow_id}") + await scheduler.start() + try: + # Run forever; the scheduler tick is in the background. + await asyncio.Event().wait() + finally: + await scheduler.stop() + + +if __name__ == "__main__": + asyncio.run(main()) +``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dfbba48a7..976fd4ce9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@hugeicons/core-free-icons": "^4.1.4", "@hugeicons/react": "^1.1.6", + "@tanstack/react-virtual": "^3.14.2", "framer-motion": "^12.38.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", @@ -38,8 +39,8 @@ "postcss": "^8.5.8", "tailwindcss": "^3.4.19", "typescript": "5.5.4", - "vite": "^6.0.0", - "vitest": "^2.1.5" + "vite": "^6.4.3", + "vitest": "^4.1.8" } }, "node_modules/@adobe/css-tools": { @@ -114,13 +115,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -129,9 +130,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -139,21 +140,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -170,14 +171,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -187,14 +188,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -204,9 +205,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -214,29 +215,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -256,9 +257,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -266,9 +267,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -276,9 +277,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -286,27 +287,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -357,33 +358,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -391,14 +392,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -580,9 +581,9 @@ "license": "MIT" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -597,9 +598,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -614,9 +615,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -631,9 +632,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -648,9 +649,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -665,9 +666,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -682,9 +683,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -699,9 +700,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -716,9 +717,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -733,9 +734,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -750,9 +751,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -767,9 +768,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -784,9 +785,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -801,9 +802,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -818,9 +819,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -835,9 +836,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -852,9 +853,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -869,9 +870,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -886,9 +887,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -903,9 +904,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -920,9 +921,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -937,9 +938,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -954,9 +955,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -971,9 +972,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -988,9 +989,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1005,9 +1006,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -1195,9 +1196,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", "license": "MIT", "engines": { "node": ">=14.0.0" @@ -1572,6 +1573,33 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -1713,6 +1741,16 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -1776,6 +1814,12 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1869,86 +1913,106 @@ } }, "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" }, "funding": { "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", "dev": true, - "license": "MIT", "dependencies": { - "tinyrainbow": "^1.2.0" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", "dev": true, - "license": "MIT", "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2020,7 +2084,6 @@ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, - "license": "MIT", "engines": { "node": ">=12" } @@ -2154,16 +2217,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -2225,32 +2278,14 @@ } }, "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { "node": ">=18" } }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2595,16 +2630,6 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2637,9 +2662,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", - "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optional": true, "optionalDependencies": { @@ -2676,11 +2701,10 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true }, "node_modules/es-toolkit": { "version": "1.46.1", @@ -2693,9 +2717,9 @@ ] }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2706,32 +2730,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -2749,7 +2773,6 @@ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, - "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" } @@ -3218,13 +3241,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3259,7 +3275,6 @@ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -3394,6 +3409,16 @@ "node": ">= 6" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ] + }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", @@ -3430,21 +3455,10 @@ "license": "MIT" }, "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true }, "node_modules/performance-now": { "version": "2.1.0", @@ -3821,12 +3835,12 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "@remix-run/router": "1.23.3" }, "engines": { "node": ">=14.0.0" @@ -3836,13 +3850,13 @@ } }, "node_modules/react-router-dom": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", - "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "version": "6.30.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2", - "react-router": "6.30.3" + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" }, "engines": { "node": ">=14.0.0" @@ -4156,11 +4170,10 @@ } }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true }, "node_modules/strip-indent": { "version": "3.0.0", @@ -4371,11 +4384,13 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, - "license": "MIT" + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.16", @@ -4425,32 +4440,11 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, - "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -4542,9 +4536,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -4637,9 +4631,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { @@ -4711,1176 +4705,151 @@ } } }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">=12.0.0" }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/vite-node/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ - "android" + "darwin" ], "engines": { - "node": ">=12" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/vite-node/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vite-node/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, "engines": { - "node": ">=12" + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } } }, - "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite-node/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vite-node/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vite-node/node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vitest/node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vitest/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/vitest/node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/w3c-xmlserializer": { diff --git a/frontend/package.json b/frontend/package.json index 0a9e3c3ef..8d0870432 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "dependencies": { "@hugeicons/core-free-icons": "^4.1.4", "@hugeicons/react": "^1.1.6", + "@tanstack/react-virtual": "^3.14.2", "framer-motion": "^12.38.0", "html2canvas": "^1.4.1", "jspdf": "^4.2.1", @@ -46,7 +47,13 @@ "postcss": "^8.5.8", "tailwindcss": "^3.4.19", "typescript": "5.5.4", - "vite": "^6.0.0", - "vitest": "^2.1.5" + "vite": "^6.4.3", + "vitest": "^4.1.8" + }, + "overrides": { + "esbuild": "^0.28.1", + "react-router": "^6.30.4", + "dompurify": "^3.4.10", + "@babel/core": "^7.29.7" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2d3274828..9c3e6412b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,11 +6,13 @@ import Toolkit from './pages/Toolkit' import ToolConfig from './pages/ToolConfig' import Findings from './pages/Findings' import Reports from './pages/Reports' +import ReportCompare from './pages/ReportCompare' import Settings from './pages/Settings' import Scans from './pages/Scans' import TaskDetails from './pages/TaskDetails' import Workflows from './pages/Workflows' import ApiKeySetupScreen from './components/ApiKeySetupScreen' +import ErrorBoundary from './components/ErrorBoundary' import { ThemeProvider } from './components/ThemeContext' import { ToastProvider } from './components/ToastContext' @@ -27,6 +29,7 @@ export function AppRoutes() { <Route path={routes.findings} element={<Findings />} /> <Route path={routes.scans} element={<Scans />} /> <Route path={routes.reports} element={<Reports />} /> + <Route path={routes.reportsCompare} element={<ReportCompare />} /> <Route path={routes.workflows} element={<Workflows />} /> <Route path={routes.settings} element={<Settings />} /> <Route path={routes.task} element={<TaskDetails />} /> @@ -52,17 +55,19 @@ export default function App() { <ThemeProvider> <I18nProvider> <ToastProvider> - {needsKey ? ( - // Render ONLY the setup screen — no page routes are mounted, so no - // API calls can fire and spam 401 failures before the key is saved. - <ApiKeySetupScreen onSaved={() => setNeedsKey(false)} /> - ) : ( - <Router> - <AppShell> - <AppRoutes /> - </AppShell> - </Router> - )} + <ErrorBoundary> + {needsKey ? ( + // Render ONLY the setup screen — no page routes are mounted, so no + // API calls can fire and spam 401 failures before the key is saved. + <ApiKeySetupScreen onSaved={() => setNeedsKey(false)} /> + ) : ( + <Router> + <AppShell> + <AppRoutes /> + </AppShell> + </Router> + )} + </ErrorBoundary> </ToastProvider> </I18nProvider> </ThemeProvider> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b3cb9fcff..c260f056f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -49,6 +49,201 @@ export interface PluginAvailability { guidance?: string | null } +export interface ExecutionContext { + target_policy_id?: string | null + scan_profile: string + credential_profile_id?: string | null + session_profile_id?: string | null + validation_mode: 'detect_only' | 'proof' | 'controlled_extract' + evidence_level: 'minimal' | 'standard' | 'full' +} + +export interface EvidenceRecord { + type: string + label?: string + value?: unknown + artifact_ref?: string | null + source?: string + observed_at?: string + confidence?: number +} + +export interface FindingRecord { + [key: string]: unknown + id?: string + task_id?: string + plugin_id?: string + severity: string + category: string + title: string + target: string + description?: string + remediation?: string + discovered_at?: string + cvss?: number + cve?: string + cpe?: string + risk_score?: number + risk_factors?: Array<Record<string, unknown>> + exploitability?: number + confidence?: number + validated?: boolean + validation_method?: string + confidence_reason?: string + evidence?: EvidenceRecord[] + asset_refs?: string[] + asset_id?: string + finding_group_id?: string + finding_kind?: 'observation' | 'suspected_issue' | 'validated_issue' + occurrence_count?: number + corroborating_sources?: string[] + evidence_count?: number + analyst_status?: string + retest_status?: string + service_fingerprint?: string + references?: Array<Record<string, unknown>> + asset_exposure?: string + first_seen_at?: string + last_seen_at?: string + metadata?: Record<string, unknown> +} + +export interface FindingGroup { + id: string + title: string + severity: string + category?: string + target?: string + asset_id?: string + finding_kind?: string + validated?: boolean + cve?: string + cpe?: string + confidence?: number + confidence_reason?: string + first_seen_at?: string + last_seen_at?: string + occurrence_count?: number + evidence_count?: number + corroborating_sources?: string[] + analyst_status?: string + retest_status?: string + latest_finding_id?: string + findings?: FindingRecord[] +} + +export interface AssetServiceRecord { + id?: string + asset_id?: string + target?: string + host: string + ip?: string | null + port?: number | null + protocol?: string | null + service?: string | null + product?: string | null + version?: string | null + cpe?: string | null + confidence?: number | null + title?: string | null + banner?: string | null + cert_subject?: string | null + cert_san?: string[] + cert_expiry?: string | null + service_fingerprint?: string | null + metadata?: Record<string, unknown> +} + +export interface AssetSummaryEntry { + asset_id: string + label?: string + target?: string + services?: AssetServiceRecord[] + finding_count?: number + validated_count?: number + highest_severity?: string +} + +export interface ScanDiff { + new: FindingGroup[] + resolved: FindingGroup[] + changed: Array<{ + group_id: string + before: FindingRecord + after: FindingRecord + }> + summary: { + new_count: number + resolved_count: number + changed_count: number + } +} + +export interface FindingsResponse { + findings?: FindingRecord[] + finding_groups?: FindingGroup[] +} + +export interface TaskResultResponse { + task_id: string + plugin_id: string + tool: string + target: string + timestamp: string + duration_seconds?: number + status: string + execution_context?: ExecutionContext + summary?: string[] + severity_counts?: Record<string, number> + findings?: FindingRecord[] + finding_groups?: FindingGroup[] + asset_summary?: AssetSummaryEntry[] + scan_diff?: ScanDiff + structured?: Record<string, unknown> + raw_output_path?: string + raw_output_excerpt?: string + raw_output?: string + command_used?: string + errors?: Array<{ message: string }> + error_message?: string + exit_code?: number + metadata?: Record<string, unknown> +} + +export interface NamedResourceList<T> { + items: T[] + total: number +} + +export interface TargetPolicy { + id: string + name: string + description?: string | null + allow_public_targets: boolean + allow_exploit_validation: boolean + allow_authenticated_scan: boolean + default_validation_mode: string + allowed_targets?: string[] + metadata?: Record<string, unknown> +} + +export interface CredentialProfile { + id: string + name: string + username_secret_name?: string | null + password_secret_name?: string | null + extra_headers?: Record<string, unknown> + login_recipe?: Record<string, unknown> +} + +export interface SessionProfile { + id: string + name: string + cookie_secret_name?: string | null + extra_headers?: Record<string, unknown> + notes?: string | null +} + export interface PluginListItem { id: string name: string @@ -59,6 +254,10 @@ export interface PluginListItem { icon: string requires_consent: boolean consent_message?: string | null + capabilities?: string[] + implementation_status?: 'native' | 'integrated' | 'placeholder' + supports_authenticated_crawling?: boolean + supports_session_reuse?: boolean availability: PluginAvailability } @@ -74,6 +273,9 @@ export interface PluginSchemaResponse { fields: PluginFieldSchema[] presets: Record<string, Record<string, unknown>> safety: Record<string, unknown> + implementation_status?: 'native' | 'integrated' | 'placeholder' + supports_authenticated_crawling?: boolean + supports_session_reuse?: boolean } export interface TaskStartResponse { @@ -115,27 +317,30 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> { const apiKey = getApiKey() const authHeaders: Record<string, string> = apiKey ? { 'X-Api-Key': apiKey } : {} - const response = await fetch(`${API_BASE}${path}`, { - ...init, - headers: { - ...authHeaders, - ...(init?.headers as Record<string, string> | undefined), - }, - signal: controller.signal, - }) - window.clearTimeout(timeoutId) - - if (response.status === 401) { - // Notify the app so it can show the API-key setup UI without every - // caller needing to handle auth independently. - window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT)) - throw new Error('AUTH_REQUIRED') - } - - if (!response.ok) { - throw new Error(`Request failed: ${response.status}`) + try { + const response = await fetch(`${API_BASE}${path}`, { + ...init, + headers: { + ...authHeaders, + ...(init?.headers as Record<string, string> | undefined), + }, + signal: controller.signal, + }) + + if (response.status === 401) { + // Notify the app so it can show the API-key setup UI without every + // caller needing to handle auth independently. + window.dispatchEvent(new CustomEvent(AUTH_REQUIRED_EVENT)) + throw new Error('AUTH_REQUIRED') + } + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`) + } + return response.json() + } finally { + window.clearTimeout(timeoutId) } - return response.json() } export function getHealth() { @@ -160,7 +365,11 @@ export function getDashboardSummary() { export function getFindings() { - return request('/findings') + return request<FindingsResponse>('/findings') +} + +export function getFindingGroups() { + return request<{ groups: FindingGroup[]; total: number }>('/finding-groups') } @@ -168,6 +377,92 @@ export function getReports() { return request('/reports') } +export type NotificationChannelType = 'webhook' | 'email' +export type NotificationSeverityThreshold = 'critical' | 'high' | 'medium' | 'low' | 'info' + +export interface NotificationRule { + id: string + name: string + severity_threshold: NotificationSeverityThreshold | string + channel_type: NotificationChannelType | string + target_url_or_email: string + is_active: boolean + created_at: string + updated_at: string +} + +export interface NotificationHistoryRow { + id: string + rule_id: string + finding_id: string + status: 'success' | 'failed' | string + error_message?: string | null + sent_at: string +} + +export interface NotificationRuleCreatePayload { + name: string + severity_threshold: NotificationSeverityThreshold + channel_type: NotificationChannelType + target_url_or_email: string + is_active: boolean +} + +export interface NotificationRuleUpdatePayload { + name?: string + severity_threshold?: NotificationSeverityThreshold + channel_type?: NotificationChannelType + target_url_or_email?: string + is_active?: boolean +} + +export async function listNotificationRules(): Promise<NotificationRule[]> { + const data: any = await request('/notifications/rules') + const rules = Array.isArray(data) ? data : data?.rules + return Array.isArray(rules) ? (rules as NotificationRule[]) : [] +} + +export async function createNotificationRule(payload: NotificationRuleCreatePayload): Promise<NotificationRule> { + return request<NotificationRule>('/notifications/rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function updateNotificationRule(ruleId: string, payload: NotificationRuleUpdatePayload): Promise<NotificationRule> { + return request<NotificationRule>(`/notifications/rules/${ruleId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export async function deleteNotificationRule(ruleId: string): Promise<{ rule_id: string; deleted: boolean }> { + return request<{ rule_id: string; deleted: boolean }>(`/notifications/rules/${ruleId}`, { + method: 'DELETE', + }) +} + +export async function listNotificationHistory(params?: { + rule_id?: string + limit?: number + offset?: number +}): Promise<{ history: NotificationHistoryRow[]; total: number; limit: number; offset: number }> { + const sp = new URLSearchParams() + if (params?.rule_id) sp.set('rule_id', params.rule_id) + if (typeof params?.limit === 'number') sp.set('limit', String(params.limit)) + if (typeof params?.offset === 'number') sp.set('offset', String(params.offset)) + const suffix = sp.toString() ? `?${sp.toString()}` : '' + const data: any = await request(`/notifications/history${suffix}`) + return { + history: Array.isArray(data?.history) ? (data.history as NotificationHistoryRow[]) : [], + total: Number(data?.total ?? 0), + limit: Number(data?.limit ?? (params?.limit ?? 50)), + offset: Number(data?.offset ?? (params?.offset ?? 0)), + } +} + export function getTasks(params?: URLSearchParams) { const suffix = params ? `?${params.toString()}` : '' return request(`/tasks${suffix}`) @@ -180,14 +475,35 @@ export function getTaskStatus(taskId: string): Promise<any> { } export function getTaskResult(taskId: string): Promise<any> { - return request<any>(`/task/${taskId}/result`) + return request<TaskResultResponse>(`/task/${taskId}/result`) +} + +export function getTaskDiff(taskId: string): Promise<ScanDiff> { + return request<ScanDiff>(`/task/${taskId}/diff`) } -export function startTask(plugin_id: string, inputs: Record<string, unknown>, consent_granted: boolean, preset?: string) { +export function startTask( + plugin_id: string, + inputs: Record<string, unknown>, + consent_granted: boolean, + preset?: string, + execution_context?: Partial<ExecutionContext>, +) { return request<TaskStartResponse>('/task/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ plugin_id, inputs, consent_granted, preset }), + body: JSON.stringify({ + plugin_id, + inputs, + consent_granted, + preset, + execution_context: { + scan_profile: 'standard', + validation_mode: 'proof', + evidence_level: 'standard', + ...execution_context, + }, + }), }) } @@ -228,6 +544,8 @@ export function streamTask(taskId: string, onEvent: (ev: MessageEvent) => void) export interface WorkflowStep { plugin_id: string inputs: Record<string, unknown> + preset?: string + execution_context?: ExecutionContext } export interface Workflow { @@ -338,3 +656,35 @@ export function deleteWorkflow(workflowId: string): Promise<{ deleted: boolean } method: 'DELETE', }) } + +export function listTargetPolicies() { + return request<NamedResourceList<TargetPolicy>>('/target-policies') +} + +export function createTargetPolicy(payload: Partial<TargetPolicy>) { + return request<TargetPolicy>('/target-policies', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +export function listCredentialProfiles() { + return request<NamedResourceList<CredentialProfile>>('/credential-profiles') +} + +export function listSessionProfiles() { + return request<NamedResourceList<SessionProfile>>('/session-profiles') +} + +export function getCrawlRuns() { + return request('/crawl-runs') +} + +export function getAssetServices() { + return request('/assets/services') +} + +export function getKnowledgebaseStatus() { + return request('/knowledgebase/status') +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 1c73e91bb..b0f828341 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useRef, useCallback } from 'react' import { NavLink, useLocation } from 'react-router-dom' import Sidebar from './Sidebar' import Background from './Background' @@ -14,23 +14,28 @@ export default function AppShell({ children }: AppShellProps) { useShortcuts() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const menuButtonRef = useRef<HTMLButtonElement>(null) + const drawerRef = useRef<HTMLDivElement>(null) const [sidebarExpanded, setSidebarExpanded] = useState(() => { const saved = localStorage.getItem('sidebar-expanded') return saved !== null ? JSON.parse(saved) : true }) - // Brief hack to sync sidebar state without a full context provider useEffect(() => { const handleStorage = () => { const saved = localStorage.getItem('sidebar-expanded') if (saved !== null) setSidebarExpanded(JSON.parse(saved)) } + const handleSidebarChange = (e: Event) => { + const detail = (e as CustomEvent).detail + if (typeof detail === 'boolean') setSidebarExpanded(detail) + } window.addEventListener('storage', handleStorage) - const interval = setInterval(handleStorage, 100) + window.addEventListener('sidebar-state-changed', handleSidebarChange) return () => { window.removeEventListener('storage', handleStorage) - clearInterval(interval) + window.removeEventListener('sidebar-state-changed', handleSidebarChange) } }, []) @@ -38,6 +43,52 @@ export default function AppShell({ children }: AppShellProps) { setMobileMenuOpen(false) }, [pathname]) + useEffect(() => { + if (!mobileMenuOpen) return + const previousOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = previousOverflow + } + }, [mobileMenuOpen]) + + useEffect(() => { + if (mobileMenuOpen) { + const firstFocusable = drawerRef.current?.querySelector<HTMLElement>( + 'a, button, [tabindex]:not([tabindex="-1"])' + ) + firstFocusable?.focus() + } else { + menuButtonRef.current?.focus() + } + }, [mobileMenuOpen]) + + const handleDrawerKeyDown = useCallback( + (e: React.KeyboardEvent<HTMLDivElement>) => { + if (e.key === 'Escape') { + setMobileMenuOpen(false) + return + } + if (e.key !== 'Tab') return + const focusable = Array.from( + drawerRef.current?.querySelectorAll<HTMLElement>( + 'a, button, [tabindex]:not([tabindex="-1"])' + ) ?? [] + ) + if (focusable.length === 0) return + const first = focusable[0] + const last = focusable[focusable.length - 1] + if (e.shiftKey && document.activeElement === first) { + e.preventDefault() + last.focus() + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault() + first.focus() + } + }, + [] + ) + const desktopSidebarWidth = sidebarExpanded ? 220 : 64 const mobilePrimaryNav = [ { to: routes.dashboard, icon: 'monitoring', label: 'Dashboard' }, @@ -63,11 +114,14 @@ export default function AppShell({ children }: AppShellProps) { <Background state="idle" /> <div className="flex bg-charcoal-dark min-h-screen"> <Sidebar /> - <div className="lg:hidden fixed inset-x-0 top-0 z-40 bg-secondary border-b border-accent-silver/10 h-14 px-4 flex items-center justify-between"> + <div className="lg:hidden fixed inset-x-0 top-0 z-[60] bg-[var(--bg-secondary)] border-b border-accent-silver/10 h-14 px-4 flex items-center justify-between"> <button + ref={menuButtonRef} onClick={() => setMobileMenuOpen((prev) => !prev)} className="w-9 h-9 border border-accent-silver/20 flex items-center justify-center text-silver-bright bg-charcoal-dark" aria-label="Toggle navigation menu" + aria-expanded={mobileMenuOpen} + aria-controls="mobile-nav-drawer" > <span className="material-symbols-outlined text-[20px]"> {mobileMenuOpen ? 'close' : 'menu'} @@ -78,10 +132,21 @@ export default function AppShell({ children }: AppShellProps) { </div> {mobileMenuOpen && ( - <div className="lg:hidden fixed inset-0 z-40 bg-black/60" onClick={() => setMobileMenuOpen(false)}> + <> + <button + type="button" + className="lg:hidden fixed inset-0 z-50 bg-charcoal-dark/80 backdrop-blur-sm" + onClick={() => setMobileMenuOpen(false)} + aria-label="Close navigation menu" + /> <div - className="absolute top-14 left-0 right-0 bg-secondary border-b border-accent-silver/10 p-4" - onClick={(e) => e.stopPropagation()} + id="mobile-nav-drawer" + ref={drawerRef} + role="dialog" + aria-modal="true" + aria-label="Navigation menu" + className="lg:hidden fixed top-14 left-0 right-0 z-50 bg-[var(--bg-secondary)] border-b border-accent-silver/10 p-4 shadow-[0_12px_32px_rgba(0,0,0,0.6)]" + onKeyDown={handleDrawerKeyDown} > <nav className="grid grid-cols-2 gap-2"> {mobileDrawerNav.map((item) => ( @@ -101,7 +166,7 @@ export default function AppShell({ children }: AppShellProps) { ))} </nav> </div> - </div> + </> )} <main @@ -111,7 +176,7 @@ export default function AppShell({ children }: AppShellProps) { {children} </main> - <nav className="lg:hidden fixed bottom-0 inset-x-0 z-40 h-16 bg-secondary border-t border-accent-silver/10 grid grid-cols-5"> + <nav className="lg:hidden fixed bottom-0 inset-x-0 z-40 h-16 bg-[var(--bg-secondary)] border-t border-accent-silver/10 grid grid-cols-5"> {mobilePrimaryNav.map((item) => ( <NavLink key={item.to} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 000000000..3d0428831 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,145 @@ +import React from 'react' + +type ErrorBoundaryProps = { + children: React.ReactNode +} + +type ErrorBoundaryState = { + hasError: boolean + error?: Error +} + +export default class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { + hasError: false, + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { + hasError: true, + error, + } + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error('Unhandled frontend error:', error, errorInfo) + } + + handleReload = () => { + window.location.reload() + } + + handleReset = () => { + this.setState({ hasError: false, error: undefined }) + } + + render() { + if (this.state.hasError) { + return ( + <main + role="alert" + aria-labelledby="error-boundary-title" + style={{ + minHeight: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '2rem', + background: 'var(--bg-primary)', + color: 'var(--text-primary)', + }} + > + <section + style={{ + maxWidth: '620px', + width: '100%', + padding: '2rem', + border: '1px solid rgba(192, 192, 200, 0.18)', + borderRadius: '18px', + background: + 'linear-gradient(145deg, var(--bg-secondary), var(--bg-primary))', + boxShadow: '0 24px 80px rgba(0, 0, 0, 0.35)', + }} + > + <p + style={{ + color: 'var(--rag-amber)', + fontFamily: 'var(--font-mono)', + fontSize: '0.8rem', + marginBottom: '0.75rem', + textTransform: 'uppercase', + letterSpacing: '0.08em', + }} + > + Frontend exception caught + </p> + + <h1 id="error-boundary-title" style={{ marginBottom: '0.75rem' }}> + Something went wrong + </h1> + + <p style={{ marginBottom: '1.5rem', lineHeight: 1.6 }}> + SecuScan recovered from an unexpected interface error. You can try + returning to the app or reload the page. + </p> + + {this.state.error?.message ? ( + <pre + style={{ + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + marginBottom: '1.5rem', + padding: '1rem', + borderRadius: '12px', + background: 'rgba(255, 255, 255, 0.04)', + color: 'var(--text-secondary)', + fontFamily: 'var(--font-mono)', + fontSize: '0.85rem', + }} + > + {this.state.error.message} + </pre> + ) : null} + + <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}> + <button + type="button" + onClick={this.handleReset} + style={{ + padding: '0.75rem 1rem', + borderRadius: '10px', + border: '1px solid rgba(192, 192, 200, 0.25)', + background: 'var(--bg-elevated)', + color: 'var(--text-primary)', + cursor: 'pointer', + }} + > + Return to app + </button> + + <button + type="button" + onClick={this.handleReload} + style={{ + padding: '0.75rem 1rem', + borderRadius: '10px', + border: '1px solid rgba(192, 192, 200, 0.25)', + background: 'transparent', + color: 'var(--text-secondary)', + cursor: 'pointer', + }} + > + Reload page + </button> + </div> + </section> + </main> + ) + } + + return this.props.children + } +} \ No newline at end of file diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx index 5f7728b4e..b79b27605 100644 --- a/frontend/src/components/Pagination.tsx +++ b/frontend/src/components/Pagination.tsx @@ -1,5 +1,4 @@ import React from "react"; - interface PaginationProps { page: number; total: number; @@ -8,7 +7,6 @@ interface PaginationProps { onPrev: () => void; onNext: () => void; } - export default function Pagination({ page, total, @@ -21,37 +19,54 @@ export default function Pagination({ const end = Math.min(page * limit, total); const isFirst = page === 1; const isLast = end >= total; - return ( <div className="flex flex-col sm:flex-row items-center justify-between gap-6 border-t-4 border-silver-bright/10 pt-8"> - <p className="text-[10px] font-mono text-silver/30 uppercase tracking-widest italic"> - Showing_Records:{" "} - <span className="text-silver-bright"> - {start}–{end} - </span>{" "} - // Total: <span className="text-rag-blue">{total}</span> + <p + aria-live="polite" + aria-atomic="true" + className="text-[10px] font-mono text-silver/30 uppercase tracking-widest italic" + > + <span className="sr-only"> + {total === 0 + ? "No records found." + : `Showing records ${start} to ${end} of ${total} total.`} + </span> + <span aria-hidden="true"> + Showing_Records:{" "} + <span className="text-silver-bright"> + {start}–{end} + </span>{" "} + // Total: <span className="text-rag-blue">{total}</span> + </span> </p> <div className="flex items-center gap-4"> <button onClick={onPrev} disabled={isFirst || loading} + aria-disabled={isFirst || loading} + aria-label="Go to previous page" className="px-6 py-3 text-[10px] font-black uppercase tracking-widest border-2 border-silver-bright/10 text-silver/40 hover:border-rag-blue hover:text-rag-blue transition-all flex items-center gap-2 disabled:opacity-20 disabled:cursor-not-allowed italic" > - <span className="material-symbols-outlined text-sm">arrow_back</span> + <span className="material-symbols-outlined text-sm" aria-hidden="true">arrow_back</span> Prev_Page </button> - <div className="bg-charcoal-dark border-2 border-black px-4 py-3 shadow-[3px_3px_0px_0px_rgba(0,0,0,1)]"> - <span className="text-[10px] font-black font-mono text-rag-blue"> + <div + aria-label={`Page ${page}`} + className="bg-charcoal-dark border-2 border-black px-4 py-3 shadow-[3px_3px_0px_0px_rgba(0,0,0,1)]" + > + <span className="text-[10px] font-black font-mono text-rag-blue" aria-hidden="true"> {page} </span> </div> <button onClick={onNext} disabled={isLast || loading} + aria-disabled={isLast || loading} + aria-label="Go to next page" className="px-6 py-3 text-[10px] font-black uppercase tracking-widest border-2 border-silver-bright/10 text-silver/40 hover:border-rag-blue hover:text-rag-blue transition-all flex items-center gap-2 disabled:opacity-20 disabled:cursor-not-allowed italic" > Next_Page - <span className="material-symbols-outlined text-sm"> + <span className="material-symbols-outlined text-sm" aria-hidden="true"> arrow_forward </span> </button> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 99bf5e2b8..fb7b00546 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -108,6 +108,7 @@ export default function Sidebar() { useEffect(() => { localStorage.setItem('sidebar-expanded', JSON.stringify(isExpanded)) + window.dispatchEvent(new CustomEvent('sidebar-state-changed', { detail: isExpanded })) }, [isExpanded]) return ( diff --git a/frontend/src/data/scanTools.ts b/frontend/src/data/scanTools.ts index f650c5ef7..673c8a66c 100644 --- a/frontend/src/data/scanTools.ts +++ b/frontend/src/data/scanTools.ts @@ -31,8 +31,8 @@ export const scanTools: ScanTool[] = [ { id: 'wpscan', name: 'WPScan', purpose: 'Specialized WordPress vulnerability auditor', riskLevel: 'active', presetCompatibility: 'none', requiresConsent: true, category: 'vulnerability', isQuickStart: true }, { id: 'nuclei', name: 'Nuclei', purpose: 'Template-based vulnerability detection at scale', riskLevel: 'active', presetCompatibility: 'both', requiresConsent: true, category: 'vulnerability', isQuickStart: true }, { id: 'dir_discovery', name: 'Directory Discovery', purpose: 'Fuzzing for hidden files and directories', riskLevel: 'active', presetCompatibility: 'deep-scan', requiresConsent: true, category: 'vulnerability' }, - { id: 'sqli_checker', name: 'SQLi Checker', purpose: 'Lightweight SQL injection feasibility testing', riskLevel: 'active', presetCompatibility: 'none', requiresConsent: true, category: 'vulnerability', subcategory: 'web' }, - { id: 'sqlmap', name: 'SQLMap', purpose: 'Automated SQL injection and database takeover', riskLevel: 'aggressive', presetCompatibility: 'none', requiresConsent: true, category: 'vulnerability', isQuickStart: true }, + { id: 'sqli_checker', name: 'SQLi Checker', purpose: 'Validate potential SQL injection vulnerabilities without exploitation', riskLevel: 'active', presetCompatibility: 'none', requiresConsent: true, category: 'vulnerability', subcategory: 'web' }, + { id: 'sqlmap', name: 'SQLMap', purpose: 'Detect SQL injection issues and perform controlled database enumeration', riskLevel: 'aggressive', presetCompatibility: 'none', requiresConsent: true, category: 'vulnerability', isQuickStart: true }, { id: 'tls_inspector', name: 'TLS Inspector', purpose: 'SSL/TLS certificate and cipher strength audit', riskLevel: 'passive', presetCompatibility: 'quick-recon', requiresConsent: false, category: 'vulnerability', subcategory: 'network' }, { id: 'joomscan', name: 'JoomScan', purpose: 'Joomla CMS vulnerability and config auditor', riskLevel: 'active', presetCompatibility: 'none', requiresConsent: true, category: 'vulnerability', subcategory: 'web' }, { id: 'droopescan', name: 'DroopeScan', purpose: 'Drupal/Silverstripe plugin and theme auditor', riskLevel: 'active', presetCompatibility: 'none', requiresConsent: true, category: 'vulnerability', subcategory: 'web' }, @@ -47,7 +47,7 @@ export const scanTools: ScanTool[] = [ { id: 'volatility', name: 'Volatility3', purpose: 'Advanced memory forensics and artifact extraction', riskLevel: 'active', presetCompatibility: 'none', requiresConsent: true, category: 'exploit' }, // --- PENDING EXPLOIT MODULES (From Reference) --- { id: 'sniper', name: 'Sniper: Auto-Exploiter', purpose: 'Validate critical CVEs by automatic exploitation.', riskLevel: 'aggressive', presetCompatibility: 'none', requiresConsent: true, category: 'exploit' }, - { id: 'sqli_exploiter', name: 'SQLi Exploiter', purpose: 'Exploit SQL injection in web apps to extract data.', riskLevel: 'aggressive', presetCompatibility: 'none', requiresConsent: true, category: 'exploit' }, + { id: 'sqli_exploiter', name: 'SQLi Exploiter', purpose: 'Exploitation-focused SQL injection workflow for data extraction', riskLevel: 'aggressive', presetCompatibility: 'none', requiresConsent: true, category: 'exploit' }, { id: 'xss_exploiter', name: 'XSS Exploiter', purpose: 'Exploit XSS in real life-attacks, extract cookies and data.', riskLevel: 'aggressive', presetCompatibility: 'none', requiresConsent: true, category: 'exploit' }, { id: 'http_request_logger', name: 'HTTP Request Logger', purpose: 'Handle incoming HTTP requests and record data.', riskLevel: 'passive', presetCompatibility: 'none', requiresConsent: true, category: 'exploit' }, { id: 'subdomain_takeover', name: 'Subdomain Takeover', purpose: 'Discover dangling DNS entries pointing to external services.', riskLevel: 'active', presetCompatibility: 'none', requiresConsent: false, category: 'exploit' }, diff --git a/frontend/src/hooks/useVirtualList.ts b/frontend/src/hooks/useVirtualList.ts new file mode 100644 index 000000000..b11c1fe8d --- /dev/null +++ b/frontend/src/hooks/useVirtualList.ts @@ -0,0 +1,50 @@ +import { useRef } from 'react' +import { useVirtualizer, type VirtualizerOptions } from '@tanstack/react-virtual' + +export interface UseVirtualListOptions<T> + extends Partial< + Omit< + VirtualizerOptions<HTMLDivElement, Element>, + 'count' | 'getScrollElement' | 'estimateSize' + > + > { + items: T[] + estimateSize: number | ((index: number) => number) + overscan?: number +} + +/** + * Thin wrapper around @tanstack/react-virtual's useVirtualizer. + * + * Returns: + * - `parentRef` — attach to the scrollable container div + * - `virtualizer` — the virtualizer instance + * - `virtualItems` — the currently visible virtual items + * - `totalSize` — total scrollable height in px + */ +export function useVirtualList<T>({ + items, + estimateSize, + overscan = 5, + ...rest +}: UseVirtualListOptions<T>) { + const parentRef = useRef<HTMLDivElement>(null) + + const estimateFn = + typeof estimateSize === 'number' ? () => estimateSize : estimateSize + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: estimateFn, + overscan, + ...rest, + }) + + return { + parentRef, + virtualizer, + virtualItems: virtualizer.getVirtualItems(), + totalSize: virtualizer.getTotalSize(), + } +} \ No newline at end of file diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index 3fa376b2b..fe65c3274 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -1,9 +1,11 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' import { motion } from 'framer-motion' +import { useVirtualizer } from '@tanstack/react-virtual' import { getFindings } from '../api' import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' import SavedViewsPanel from '../components/SavedViewsPanel' import { useSavedViews, FilterPreset } from '../hooks/useSavedViews' + type RiskFactor = { factor: string label: string @@ -16,6 +18,8 @@ type RiskFactor = { type Finding = { id: string + finding_group_id?: string + asset_id?: string severity: string category: string title: string @@ -30,6 +34,22 @@ type Finding = { risk_factors?: RiskFactor[] exploitability?: number confidence?: number + validated?: boolean + validation_method?: string + confidence_reason?: string + evidence?: Array<Record<string, unknown>> + asset_refs?: string[] + finding_kind?: 'observation' | 'suspected_issue' | 'validated_issue' + occurrence_count?: number + corroborating_sources?: string[] + evidence_count?: number + analyst_status?: string + retest_status?: string + first_seen_at?: string + last_seen_at?: string + service_fingerprint?: string + cpe?: string + references?: Array<Record<string, unknown>> asset_exposure?: string } @@ -101,11 +121,23 @@ function filterPillClasses(isActive: boolean) { : 'border-silver-bright/10 bg-charcoal-dark text-silver/65 hover:border-silver-bright/30 hover:text-silver-bright' } -const filterLabelClass = 'text-[10px] font-black uppercase tracking-[0.2em] text-silver-bright' +const filterLabelClass ='block text-[10px] font-black uppercase tracking-[0.2em] text-silver-bright' const filterControlClass = 'h-11 w-full border-2 border-silver-bright/10 bg-charcoal-dark px-3 text-xs font-mono text-silver-bright focus:border-rag-red focus:outline-none' -type SortMode = 'severity' | 'newest' | 'oldest' | 'target' +type SortMode = 'risk' | 'severity' | 'newest' | 'oldest' | 'target' + +// ─── Virtual row types ──────────────────────────────────────────────────────── + +type HeaderRow = { kind: 'header'; severity: string; count: number } +type FindingRow = { kind: 'finding'; finding: Finding & { status: FindingStatus }; isLastInGroup: boolean } +type VirtualRow = HeaderRow | FindingRow + +// Estimated heights for virtualizer +const ROW_HEIGHTS: Record<VirtualRow['kind'], number> = { + header: 72, + finding: 140, +} export default function Findings() { const [findings, setFindings] = useState<Finding[]>([]) @@ -114,7 +146,12 @@ export default function Findings() { const [filterSeverity, setFilterSeverity] = useState('all') const [filterTarget, setFilterTarget] = useState('all') const [filterScanner, setFilterScanner] = useState('all') - const [sortMode, setSortMode] = useState<SortMode>('severity') + const [filterKind, setFilterKind] = useState('all') + const [filterAnalystStatus, setFilterAnalystStatus] = useState('all') + const [filterAsset, setFilterAsset] = useState('all') + const [filterValidatedOnly, setFilterValidatedOnly] = useState(false) + const [filterHighConfidence, setFilterHighConfidence] = useState(false) + const [sortMode, setSortMode] = useState<SortMode>('risk') const [dateFrom, setDateFrom] = useState('') const [dateTo, setDateTo] = useState('') const [selectedFindingId, setSelectedFindingId] = useState<string | null>(null) @@ -175,7 +212,13 @@ export default function Findings() { findings.map((finding) => ({ ...finding, severity: normalizeSeverity(finding.severity), - status: reviewState[finding.id] || 'new', + status: reviewState[finding.id] || ( + finding.analyst_status === 'confirmed' + ? 'reviewed' + : finding.analyst_status === 'false_positive' + ? 'suppressed' + : 'new' + ), })), [findings, reviewState], ) @@ -198,12 +241,34 @@ export default function Findings() { return Array.from(seen).sort() }, [enrichedFindings]) + const uniqueAssets = useMemo(() => { + const seen = new Set<string>() + for (const finding of enrichedFindings) { + const label = finding.asset_id || finding.asset_refs?.[0] || finding.target + if (label) seen.add(label) + } + return Array.from(seen).sort() + }, [enrichedFindings]) + + const uniqueKinds = useMemo(() => { + const seen = new Set<string>() + for (const finding of enrichedFindings) { + if (finding.finding_kind) seen.add(finding.finding_kind) + } + return Array.from(seen).sort() + }, [enrichedFindings]) + + const uniqueAnalystStatuses = useMemo(() => { + const seen = new Set<string>() + for (const finding of enrichedFindings) { + if (finding.analyst_status) seen.add(finding.analyst_status) + } + return Array.from(seen).sort() + }, [enrichedFindings]) + const filteredFindings = useMemo(() => { const query = searchQuery.trim().toLowerCase() - // Compare dates using the *displayed* calendar day in the user's configured - // timezone, not raw UTC timestamps. This way a finding at 2026-05-13T20:00:00Z - // that shows as May 14 in IST correctly matches a From Date of 2026-05-14. const tz = getCurrentTimeZone() const dateFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: tz }) @@ -211,12 +276,16 @@ export default function Findings() { const matchesSeverity = filterSeverity === 'all' || finding.severity === filterSeverity const matchesTarget = filterTarget === 'all' || finding.target === filterTarget const matchesScanner = filterScanner === 'all' || finding.plugin_id === filterScanner + const assetLabel = finding.asset_id || finding.asset_refs?.[0] || finding.target + const matchesAsset = filterAsset === 'all' || assetLabel === filterAsset + const matchesKind = filterKind === 'all' || finding.finding_kind === filterKind + const matchesAnalystStatus = filterAnalystStatus === 'all' || finding.analyst_status === filterAnalystStatus + const matchesValidated = !filterValidatedOnly || Boolean(finding.validated) + const matchesHighConfidence = !filterHighConfidence || Number(finding.confidence || 0) >= 0.75 - // Date range check — derive the calendar day in the display timezone if (dateFrom || dateTo) { const parsed = parseDateSafe(finding.discovered_at) if (!parsed) return false - // en-CA locale gives us YYYY-MM-DD which matches the <input type="date"> value const displayDay = dateFormatter.format(parsed) if (dateFrom && displayDay < dateFrom) return false if (dateTo && displayDay > dateTo) return false @@ -234,13 +303,29 @@ export default function Findings() { .join(' ') .toLowerCase() - return matchesSeverity && matchesTarget && matchesScanner && haystack.includes(query) + return ( + matchesSeverity && + matchesTarget && + matchesScanner && + matchesAsset && + matchesKind && + matchesAnalystStatus && + matchesValidated && + matchesHighConfidence && + haystack.includes(query) + ) }) - }, [enrichedFindings, filterSeverity, filterTarget, filterScanner, searchQuery, dateFrom, dateTo]) + }, [enrichedFindings, filterSeverity, filterTarget, filterScanner, filterAsset, filterKind, filterAnalystStatus, filterValidatedOnly, filterHighConfidence, searchQuery, dateFrom, dateTo]) const sortedFindings = useMemo(() => { const items = [...filteredFindings] switch (sortMode) { + case 'risk': + return items.sort((a, b) => { + const ra = a.risk_score ?? 0 + const rb = b.risk_score ?? 0 + return rb - ra + }) case 'newest': return items.sort((a, b) => { const da = parseDateSafe(a.discovered_at)?.getTime() ?? 0 @@ -259,35 +344,39 @@ export default function Findings() { ) case 'severity': default: - // Keep the original severity-group ordering; groupedFindings handles it. return items } }, [filteredFindings, sortMode]) - const groupedFindings = useMemo( - () => - severityOrder.map((severity) => ({ - severity, - items: sortedFindings.filter((finding) => finding.severity === severity), - })), - [sortedFindings], - ) - - const selectedFinding = - filteredFindings.find((finding) => finding.id === selectedFindingId) ?? - filteredFindings[0] ?? - null - - useEffect(() => { - if (!selectedFinding) { - setSelectedFindingId(null) - return - } - - if (!filteredFindings.some((finding) => finding.id === selectedFinding.id)) { - setSelectedFindingId(filteredFindings[0]?.id ?? null) + // Build the flat virtual row list: header + findings per severity group + // For non-severity sort modes, all findings appear in a single flat list + const virtualRows = useMemo<VirtualRow[]>(() => { + const rows: VirtualRow[] = [] + if (sortMode === 'severity') { + for (const severity of severityOrder) { + const items = filteredFindings.filter((f) => f.severity === severity) + if (items.length === 0) continue + rows.push({ kind: 'header', severity, count: items.length }) + items.forEach((finding, idx) => { + rows.push({ + kind: 'finding', + finding, + isLastInGroup: idx === items.length - 1, + }) + }) + } + } else { + // For newest/oldest/target sort — single flat list, no headers + sortedFindings.forEach((finding, idx) => { + rows.push({ + kind: 'finding', + finding, + isLastInGroup: idx === sortedFindings.length - 1, + }) + }) } - }, [filteredFindings, selectedFinding]) + return rows + }, [filteredFindings, sortedFindings, sortMode]) const countsBySeverity = useMemo(() => { return severityOrder.reduce<Record<string, number>>((acc, severity) => { @@ -306,24 +395,48 @@ export default function Findings() { [enrichedFindings, filteredFindings, countsBySeverity], ) + const selectedFinding = + sortedFindings.find((finding) => finding.id === selectedFindingId) ?? + sortedFindings[0] ?? + null + + useEffect(() => { + if (!selectedFinding) { + setSelectedFindingId(null) + return + } + if (!sortedFindings.some((finding) => finding.id === selectedFinding.id)) { + setSelectedFindingId(sortedFindings[0]?.id ?? null) + } + }, [sortedFindings, selectedFinding]) + // Derives a flat list of active filter chips from non-default filter state. const activeFilters = useMemo(() => { const chips: { key: string; label: string }[] = [] if (searchQuery.trim()) chips.push({ key: 'search', label: `Search: "${searchQuery.trim()}"` }) if (filterTarget !== 'all') chips.push({ key: 'target', label: `Target: ${filterTarget}` }) if (filterScanner !== 'all') chips.push({ key: 'scanner', label: `Scanner: ${filterScanner}` }) - if (sortMode !== 'severity') chips.push({ key: 'sort', label: `Sort: ${sortMode}` }) + if (filterAsset !== 'all') chips.push({ key: 'asset', label: `Asset: ${filterAsset}` }) + if (filterKind !== 'all') chips.push({ key: 'kind', label: `Kind: ${filterKind}` }) + if (filterAnalystStatus !== 'all') chips.push({ key: 'analyst', label: `Analyst: ${filterAnalystStatus}` }) + if (filterValidatedOnly) chips.push({ key: 'validated', label: 'Validated Only' }) + if (filterHighConfidence) chips.push({ key: 'confidence', label: 'High Confidence' }) + if (sortMode !== 'risk') chips.push({ key: 'sort', label: `Sort: ${sortMode}` }) if (dateFrom) chips.push({ key: 'from', label: `From: ${dateFrom}` }) if (dateTo) chips.push({ key: 'to', label: `To: ${dateTo}` }) return chips - }, [searchQuery, filterTarget, filterScanner, sortMode, dateFrom, dateTo]) - + }, [searchQuery, filterTarget, filterScanner, filterAsset, filterKind, filterAnalystStatus, filterValidatedOnly, filterHighConfidence, sortMode, dateFrom, dateTo]) function resetAllFilters() { setFilterSeverity('all') setFilterTarget('all') setFilterScanner('all') - setSortMode('severity') + setFilterAsset('all') + setFilterKind('all') + setFilterAnalystStatus('all') + setFilterValidatedOnly(false) + setFilterHighConfidence(false) + setSortMode('risk') setDateFrom('') setDateTo('') setSearchQuery('') @@ -356,73 +469,50 @@ export default function Findings() { } } - function renderFindingRow(finding: Finding & { severity: string; status: FindingStatus }) { - const isSelected = selectedFinding?.id === finding.id - const cfg = severityConfig[finding.severity] - - return ( - <button - key={finding.id} - type="button" - onClick={() => setSelectedFindingId(finding.id)} - className={`relative block w-full px-5 py-5 text-left transition-all ${ - isSelected ? 'bg-silver-bright/6' : 'hover:bg-silver-bright/3' - }`} - > - <span className={`absolute inset-y-0 left-0 w-1 ${cfg.rail}`} /> - <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> - <div className="min-w-0 flex-1 space-y-3 pl-3"> - <div className="flex flex-wrap items-center gap-2"> - <span className={`px-2 py-1 text-[9px] font-black uppercase tracking-[0.18em] ${cfg.chip}`}> - {cfg.label} - </span> - <span className={`border px-2 py-1 text-[9px] font-black uppercase tracking-[0.18em] ${getStatusTone(finding.status)}`}> - {finding.status} - </span> - <span className="text-[10px] font-mono uppercase tracking-[0.18em] text-silver/35"> - {finding.category || 'Uncategorized'} - </span> - {finding.cve ? ( - <span className="border border-rag-blue/20 bg-rag-blue/10 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.15em] text-rag-blue"> - {finding.cve} - </span> - ) : null} - </div> - - <div> - <h3 className="text-xl font-black uppercase tracking-tight text-silver-bright">{finding.title}</h3> - <p className="mt-2 text-[11px] font-mono uppercase tracking-[0.16em] text-silver/45"> - Target // {finding.target || 'Unknown'} // Observed // {formatLocaleDate(finding.discovered_at)} - </p> - </div> + // ─── Keyboard navigation ──────────────────────────────────────────────────── + const listRef = useRef<HTMLDivElement>(null) + + function handleListKeyDown(e: React.KeyboardEvent<HTMLDivElement>) { + if (!sortedFindings.length) return + const currentIdx = selectedFinding + ? sortedFindings.findIndex((f) => f.id === selectedFinding.id) + : -1 + + if (e.key === 'ArrowDown') { + e.preventDefault() + const next = sortedFindings[Math.min(currentIdx + 1, sortedFindings.length - 1)] + if (next) setSelectedFindingId(next.id) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + const prev = sortedFindings[Math.max(currentIdx - 1, 0)] + if (prev) setSelectedFindingId(prev.id) + } +} - <p className="max-w-4xl text-sm leading-relaxed text-silver/70"> - {finding.description || 'No description provided.'} - </p> - </div> + // ─── Virtualizer ──────────────────────────────────────────────────────────── + const parentRef = useRef<HTMLDivElement>(null) - <div className="flex flex-row items-end gap-6 lg:min-w-[140px] lg:flex-col lg:items-end"> - {typeof finding.cvss === 'number' ? ( - <div className="text-right"> - <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">CVSS</p> - <p className={`text-3xl font-black italic ${finding.cvss >= 9 ? 'text-rag-red' : 'text-silver-bright'}`}> - {finding.cvss.toFixed(1)} - </p> - </div> - ) : null} + const virtualizer = useVirtualizer({ + count: virtualRows.length, + getScrollElement: () => parentRef.current, + estimateSize: (index) => ROW_HEIGHTS[virtualRows[index]?.kind ?? 'finding'], + overscan: 6, + }) - <span className={`material-symbols-outlined text-lg ${isSelected ? 'text-silver-bright' : 'text-silver/30'}`} aria-hidden="true"> - east - </span> - </div> - </div> - </button> + // Scroll selected finding into view when it changes + useEffect(() => { + if (!selectedFinding) return + const rowIdx = virtualRows.findIndex( + (row) => row.kind === 'finding' && row.finding.id === selectedFinding.id, ) - } - + if (rowIdx !== -1) { + virtualizer.scrollToIndex(rowIdx, { align: 'auto', behavior: 'smooth' }) + } + }, [selectedFindingId]) // eslint-disable-line react-hooks/exhaustive-deps return ( <div className="min-h-screen bg-charcoal-dark text-silver px-4 py-6 md:px-8 md:py-10"> <div className="mx-auto flex w-full max-w-[1600px] flex-col gap-8"> + {/* Header */} <header className="border-b-4 border-silver-bright/10 pb-8"> <div className="mb-4 inline-block bg-rag-red px-4 py-1 text-xs font-black uppercase tracking-widest text-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]"> Triage Workspace v5.1 @@ -456,39 +546,32 @@ export default function Findings() { </div> </header> + {/* Filter Bar */} <section className="border-2 border-black bg-charcoal/95 p-4 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] backdrop-blur lg:sticky lg:top-4 lg:z-20"> <div className="grid gap-4"> <div className="grid gap-4 2xl:grid-cols-[minmax(320px,1fr)_auto] 2xl:items-end"> - <div className="space-y-2"> - <label className={filterLabelClass}>Search</label> - - <div className="relative"> - <input - type="text" - value={searchQuery} - onChange={(event) => setSearchQuery(event.target.value)} - placeholder="Title, target, CVE, remediation..." - className={`${filterControlClass} px-4 pr-12 placeholder:text-silver/20`} - /> - - {searchQuery.trim() && ( - <button - type="button" - aria-label="Clear search" - onClick={() => setSearchQuery('')} - className=" - absolute right-3 top-1/2 - -translate-y-1/2 - text-silver/50 - hover:text-silver-bright - transition - " - > - ✕ - </button> - )} - </div> -</div> + <div className="space-y-2"> + <label className={filterLabelClass}>Search</label> + <div className="relative"> + <input + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder="Title, target, CVE, remediation..." + className={`${filterControlClass} px-4 pr-12 placeholder:text-silver/20`} + /> + {searchQuery.trim() && ( + <button + type="button" + aria-label="Clear search" + onClick={() => setSearchQuery('')} + className="absolute right-3 top-1/2 -translate-y-1/2 text-silver/50 hover:text-silver-bright transition" + > + ✕ + </button> + )} + </div> + </div> <div className="flex flex-wrap gap-2 pb-2 sm:pb-0 2xl:max-w-[760px] 2xl:justify-end"> <button @@ -516,7 +599,7 @@ export default function Findings() { </div> <div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end"> - <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5"> + <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-8"> <div className="space-y-2"> <label className={filterLabelClass}>Target</label> <select @@ -545,6 +628,48 @@ export default function Findings() { </select> </div> + <div className="space-y-2"> + <label className={filterLabelClass}>Asset</label> + <select + value={filterAsset} + onChange={(e) => setFilterAsset(e.target.value)} + className={filterControlClass} + > + <option value="all">All Assets</option> + {uniqueAssets.map((asset) => ( + <option key={asset} value={asset}>{asset}</option> + ))} + </select> + </div> + + <div className="space-y-2"> + <label className={filterLabelClass}>Finding Kind</label> + <select + value={filterKind} + onChange={(e) => setFilterKind(e.target.value)} + className={filterControlClass} + > + <option value="all">All Kinds</option> + {uniqueKinds.map((kind) => ( + <option key={kind} value={kind}>{kind}</option> + ))} + </select> + </div> + + <div className="space-y-2"> + <label className={filterLabelClass}>Analyst State</label> + <select + value={filterAnalystStatus} + onChange={(e) => setFilterAnalystStatus(e.target.value)} + className={filterControlClass} + > + <option value="all">All States</option> + {uniqueAnalystStatuses.map((status) => ( + <option key={status} value={status}>{status}</option> + ))} + </select> + </div> + <div className="space-y-2"> <label className={filterLabelClass}>Sort By</label> <select @@ -552,6 +677,7 @@ export default function Findings() { onChange={(e) => setSortMode(e.target.value as SortMode)} className={filterControlClass} > + <option value="risk">Risk Score (High → Low)</option> <option value="severity">Severity (High → Low)</option> <option value="newest">Newest First</option> <option value="oldest">Oldest First</option> @@ -581,6 +707,24 @@ export default function Findings() { </div> <div className="flex flex-wrap items-center gap-2"> + <label className="inline-flex h-11 items-center gap-3 border border-silver-bright/10 bg-charcoal-dark px-4 text-[10px] font-black uppercase tracking-[0.18em] text-silver/75"> + <input + type="checkbox" + checked={filterValidatedOnly} + onChange={(event) => setFilterValidatedOnly(event.target.checked)} + className="h-4 w-4 accent-[var(--accent-rag-red)]" + /> + Validated Only + </label> + <label className="inline-flex h-11 items-center gap-3 border border-silver-bright/10 bg-charcoal-dark px-4 text-[10px] font-black uppercase tracking-[0.18em] text-silver/75"> + <input + type="checkbox" + checked={filterHighConfidence} + onChange={(event) => setFilterHighConfidence(event.target.checked)} + className="h-4 w-4 accent-[var(--accent-rag-blue)]" + /> + High Confidence + </label> <SavedViewsPanel views={views} loading={viewsLoading} @@ -592,15 +736,7 @@ export default function Findings() { /> <button type="button" - onClick={() => { - setFilterSeverity('all') - setFilterTarget('all') - setFilterScanner('all') - setSortMode('severity') - setDateFrom('') - setDateTo('') - setSearchQuery('') - }} + onClick={resetAllFilters} className="h-11 w-full border border-silver-bright/20 bg-charcoal-dark px-4 text-[10px] font-black uppercase tracking-[0.18em] text-silver/65 transition-all hover:border-rag-red hover:text-silver-bright xl:w-auto xl:min-w-[180px]" > Reset Filters @@ -610,8 +746,7 @@ export default function Findings() { </div> </section> - {/* ── Active filter summary strip ──────────────────────────────────────── - Hidden when all filters are at their default values. */} + {/* ── Active filter summary strip ── */} {activeFilters.length > 0 && ( <div aria-label="active filters" @@ -631,8 +766,10 @@ export default function Findings() { </div> )} + {/* Main Split Layout */} <div className="grid gap-8 xl:grid-cols-[minmax(0,1.2fr)_420px]"> - <motion.section variants={sectionVariants} initial="hidden" animate="visible" className="space-y-5"> + {/* ── Virtualized Findings List ── */} + <motion.section variants={sectionVariants} initial="hidden" animate="visible"> {loading ? ( <div className="border-4 border-dashed border-silver-bright/10 bg-charcoal/40 px-6 py-16 text-center"> <p className="text-sm font-mono uppercase tracking-[0.25em] text-silver/50">Synchronizing findings feed...</p> @@ -642,50 +779,147 @@ export default function Findings() { <p className="text-2xl font-black uppercase tracking-[0.25em] text-silver/25 italic">No Findings Match</p> <p className="mt-3 text-xs font-mono uppercase tracking-[0.2em] text-silver/15">Adjust filters to reopen the queue.</p> </div> - ) : sortMode === 'severity' ? ( - groupedFindings.map(({ severity, items }) => { - if (items.length === 0) return null - - const config = severityConfig[severity] - - return ( - <div key={severity} className="border-2 border-black bg-charcoal shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]"> - <div className="flex w-full items-center justify-between border-b border-silver-bright/8 px-5 py-4 text-left"> - <div className="flex items-center gap-4"> - <span className={`h-3 w-3 rotate-45 ${config.rail}`} /> - <div> - <p className={`text-lg font-black uppercase tracking-[0.18em] ${config.accent}`}>{config.label}</p> - <p className="text-[10px] font-mono uppercase tracking-[0.2em] text-silver/40">{items.length} visible in queue</p> - </div> - </div> - </div> - - <div className="divide-y divide-silver-bright/6"> - {items.map((finding) => renderFindingRow(finding))} - </div> - </div> - ) - }) ) : ( - <div className="border-2 border-black bg-charcoal shadow-[8px_8px_0px_0px_rgba(0,0,0,1)]"> - <div className="flex w-full items-center justify-between border-b border-silver-bright/8 px-5 py-4 text-left"> - <div className="flex items-center gap-4"> - <span className="h-3 w-3 rotate-45 bg-silver-bright" /> - <div> - <p className="text-lg font-black uppercase tracking-[0.18em] text-silver-bright"> - {sortMode === 'newest' ? 'Newest First' : sortMode === 'oldest' ? 'Oldest First' : 'By Target'} - </p> - <p className="text-[10px] font-mono uppercase tracking-[0.2em] text-silver/40">{sortedFindings.length} visible in queue</p> - </div> - </div> - </div> - <div className="divide-y divide-silver-bright/6"> - {sortedFindings.map((finding) => renderFindingRow(finding))} + <div + ref={parentRef} + role="listbox" + aria-label="Findings list" + tabIndex={0} + onKeyDown={handleListKeyDown} + className="border-2 border-black bg-charcoal shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] focus:outline-none focus:ring-2 focus:ring-rag-red/40" + style={{ height: '72vh', overflowY: 'auto' }} + > + {/* Virtualizer inner container */} + <div + style={{ height: virtualizer.getTotalSize(), width: '100%', position: 'relative' }} + > + {virtualizer.getVirtualItems().map((virtualItem) => { + const row = virtualRows[virtualItem.index] + + return ( + <div + key={virtualItem.key} + data-index={virtualItem.index} + ref={virtualizer.measureElement} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + transform: `translateY(${virtualItem.start}px)`, + }} + > + {row.kind === 'header' ? ( + /* ── Severity group header ── */ + <div className="flex w-full items-center justify-between border-b border-silver-bright/8 px-5 py-4 bg-charcoal"> + <div className="flex items-center gap-4"> + <span className={`h-3 w-3 rotate-45 ${severityConfig[row.severity].rail}`} /> + <div> + <p className={`text-lg font-black uppercase tracking-[0.18em] ${severityConfig[row.severity].accent}`}> + {severityConfig[row.severity].label} + </p> + <p className="text-[10px] font-mono uppercase tracking-[0.2em] text-silver/40"> + {row.count} visible in queue + </p> + </div> + </div> + </div> + ) : ( + /* ── Finding row ── */ + (() => { + const { finding, isLastInGroup } = row + const isSelected = selectedFinding?.id === finding.id + const config = severityConfig[finding.severity] + + return ( + <button + key={finding.id} + type="button" + role="option" + aria-selected={isSelected} + onClick={() => setSelectedFindingId(finding.id)} + className={`relative block w-full px-5 py-5 text-left transition-all ${ + !isLastInGroup ? 'border-b border-silver-bright/6' : '' + } ${isSelected ? 'bg-silver-bright/6' : 'hover:bg-silver-bright/3'}`} + > + <span className={`absolute inset-y-0 left-0 w-1 ${config.rail}`} /> + <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"> + <div className="min-w-0 flex-1 space-y-3 pl-3"> + <div className="flex flex-wrap items-center gap-2"> + <span className={`px-2 py-1 text-[9px] font-black uppercase tracking-[0.18em] ${config.chip}`}> + {config.label} + </span> + <span className={`border px-2 py-1 text-[9px] font-black uppercase tracking-[0.18em] ${getStatusTone(finding.status)}`}> + {finding.status} + </span> + <span className="text-[10px] font-mono uppercase tracking-[0.18em] text-silver/35"> + {finding.category || 'Uncategorized'} + </span> + {finding.finding_kind ? ( + <span className="border border-silver-bright/10 bg-charcoal-dark px-2 py-1 text-[9px] font-mono uppercase tracking-[0.15em] text-silver/70"> + {finding.finding_kind.replace('_', ' ')} + </span> + ) : null} + {finding.cve ? ( + <span className="border border-rag-blue/20 bg-rag-blue/10 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.15em] text-rag-blue"> + {finding.cve} + </span> + ) : null} + {typeof finding.confidence === 'number' ? ( + <span className="border border-silver-bright/10 bg-charcoal-dark px-2 py-1 text-[9px] font-mono uppercase tracking-[0.15em] text-silver-bright"> + {(finding.confidence * 100).toFixed(0)}% confidence + </span> + ) : null} + </div> + + <div> + <h3 className="text-xl font-black uppercase tracking-tight text-silver-bright">{finding.title}</h3> + <p className="mt-2 text-[11px] font-mono uppercase tracking-[0.16em] text-silver/45"> + Target // {finding.target || 'Unknown'} // Observed // {formatLocaleDate(finding.discovered_at)} + </p> + </div> + + <p className="max-w-4xl text-sm leading-relaxed text-silver/70"> + {finding.description || 'No description provided.'} + </p> + </div> + + <div className="flex flex-row items-end gap-6 lg:min-w-[140px] lg:flex-col lg:items-end"> + {typeof finding.occurrence_count === 'number' && finding.occurrence_count > 1 ? ( + <div className="text-right"> + <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">Seen</p> + <p className="text-2xl font-black italic text-silver-bright"> + {finding.occurrence_count} + </p> + </div> + ) : null} + {typeof finding.cvss === 'number' ? ( + <div className="text-right"> + <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">CVSS</p> + <p className={`text-3xl font-black italic ${finding.cvss >= 9 ? 'text-rag-red' : 'text-silver-bright'}`}> + {finding.cvss.toFixed(1)} + </p> + </div> + ) : null} + + <span className={`material-symbols-outlined text-lg ${isSelected ? 'text-silver-bright' : 'text-silver/30'}`}> + east + </span> + </div> + </div> + </button> + ) + })() + )} + </div> + ) + })} </div> </div> )} </motion.section> + {/* ── Detail Panel (unchanged) ── */} <motion.aside variants={sectionVariants} initial="hidden" animate="visible" className="xl:sticky xl:top-32 xl:self-start"> <div className="border-4 border-black bg-charcoal shadow-[10px_10px_0px_0px_rgba(0,0,0,1)]"> {selectedFinding ? ( @@ -715,10 +949,22 @@ export default function Findings() { <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">Target</p> <p className="mt-2 text-sm font-mono uppercase tracking-[0.14em] text-silver-bright">{selectedFinding.target || 'Unknown'}</p> </div> + <div className="border border-silver-bright/8 bg-charcoal-dark p-3"> + <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">Asset</p> + <p className="mt-2 text-xs font-mono uppercase tracking-[0.14em] text-silver-bright break-all"> + {selectedFinding.asset_id || selectedFinding.asset_refs?.[0] || 'N/A'} + </p> + </div> <div className="border border-silver-bright/8 bg-charcoal-dark p-3"> <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">Category</p> <p className="mt-2 text-sm font-mono uppercase tracking-[0.14em] text-silver-bright">{selectedFinding.category || 'Uncategorized'}</p> </div> + <div className="border border-silver-bright/8 bg-charcoal-dark p-3"> + <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">Finding Kind</p> + <p className="mt-2 text-sm font-mono uppercase tracking-[0.14em] text-silver-bright"> + {selectedFinding.finding_kind?.replace('_', ' ') || 'N/A'} + </p> + </div> <div className="border border-silver-bright/8 bg-charcoal-dark p-3"> <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">Observed</p> <p className="mt-2 text-sm font-mono uppercase tracking-[0.14em] text-silver-bright"> @@ -731,6 +977,36 @@ export default function Findings() { {typeof selectedFinding.cvss === 'number' ? selectedFinding.cvss.toFixed(1) : 'N/A'} </p> </div> + <div className="border border-silver-bright/8 bg-charcoal-dark p-3"> + <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">Validation</p> + <p className="mt-2 text-sm font-mono uppercase tracking-[0.14em] text-silver-bright"> + {selectedFinding.validated ? 'Validated' : selectedFinding.validation_method || 'Unvalidated'} + </p> + </div> + <div className="border border-silver-bright/8 bg-charcoal-dark p-3"> + <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">Analyst State</p> + <p className="mt-2 text-sm font-mono uppercase tracking-[0.14em] text-silver-bright"> + {selectedFinding.analyst_status || 'N/A'} + </p> + </div> + <div className="border border-silver-bright/8 bg-charcoal-dark p-3"> + <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">CPE</p> + <p className="mt-2 text-xs font-mono uppercase tracking-[0.14em] text-silver-bright"> + {selectedFinding.cpe || 'N/A'} + </p> + </div> + <div className="border border-silver-bright/8 bg-charcoal-dark p-3"> + <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">Seen Across Scans</p> + <p className="mt-2 text-sm font-mono uppercase tracking-[0.14em] text-silver-bright"> + {selectedFinding.occurrence_count || 1} + </p> + </div> + <div className="border border-silver-bright/8 bg-charcoal-dark p-3"> + <p className="text-[9px] font-black uppercase tracking-[0.2em] text-silver/35">Evidence Count</p> + <p className="mt-2 text-sm font-mono uppercase tracking-[0.14em] text-silver-bright"> + {selectedFinding.evidence_count || selectedFinding.evidence?.length || 0} + </p> + </div> </div> {typeof selectedFinding.risk_score === 'number' && ( @@ -774,9 +1050,48 @@ export default function Findings() { <p className="mb-2 text-[10px] font-black uppercase tracking-[0.2em] text-silver/35">Evidence Brief</p> <div className="border-l-4 border-rag-red bg-charcoal-dark p-4"> <p className="text-sm leading-relaxed text-silver/78">{selectedFinding.description || 'No description provided.'}</p> + {selectedFinding.confidence_reason ? ( + <p className="mt-3 text-[11px] font-mono uppercase tracking-[0.12em] text-silver/45"> + {selectedFinding.confidence_reason} + </p> + ) : null} </div> </div> + {selectedFinding.evidence && selectedFinding.evidence.length > 0 ? ( + <div> + <p className="mb-2 text-[10px] font-black uppercase tracking-[0.2em] text-silver/35">Evidence Items</p> + <div className="space-y-2"> + {selectedFinding.evidence.slice(0, 5).map((item, index) => ( + <div key={`${selectedFinding.id}-evidence-${index}`} className="border border-silver-bright/8 bg-charcoal-dark p-3 text-[11px] font-mono text-silver/72"> + <p className="text-[10px] uppercase tracking-[0.18em] text-silver/35"> + {String(item.label || item.type || 'evidence')} + </p> + <p className="mt-2 break-words whitespace-pre-wrap text-silver-bright"> + {String(item.value ?? '')} + </p> + <p className="mt-2 text-[9px] uppercase tracking-[0.16em] text-silver/30"> + {String(item.source || 'scanner')} {item.confidence ? `// ${(Number(item.confidence) * 100).toFixed(0)}%` : ''} + </p> + </div> + ))} + </div> + </div> + ) : null} + + {selectedFinding.corroborating_sources && selectedFinding.corroborating_sources.length > 0 ? ( + <div> + <p className="mb-2 text-[10px] font-black uppercase tracking-[0.2em] text-silver/35">Corroborating Sources</p> + <div className="flex flex-wrap gap-2"> + {selectedFinding.corroborating_sources.map((source) => ( + <span key={source} className="border border-silver-bright/10 bg-charcoal-dark px-2 py-1 text-[9px] font-mono uppercase tracking-[0.15em] text-silver-bright"> + {source} + </span> + ))} + </div> + </div> + ) : null} + <div> <p className="mb-2 text-[10px] font-black uppercase tracking-[0.2em] text-silver/35">Remediation</p> <div className="border-l-4 border-rag-green bg-charcoal-dark p-4"> @@ -835,4 +1150,4 @@ export default function Findings() { </div> </div> ) -} \ No newline at end of file +} diff --git a/frontend/src/pages/ReportCompare.tsx b/frontend/src/pages/ReportCompare.tsx new file mode 100644 index 000000000..ffac31425 --- /dev/null +++ b/frontend/src/pages/ReportCompare.tsx @@ -0,0 +1,350 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { motion } from 'framer-motion' +import { HugeiconsIcon } from '@hugeicons/react' +import { + ArrowLeft02Icon, + Analytics02Icon, + Refresh01Icon, +} from '@hugeicons/core-free-icons' +import { getFindings, getReports } from '../api' +import { routes } from '../routes' +import { formatDateLong } from '../utils/date' +import { + compareFindings, + type ComparableFinding, + type ComparedFinding, + type ReportComparisonResult, +} from '../utils/compareFindings' + +type ReportOption = { + id: string + task_id: string + name: string + generated_at: string + findings: number + status: string +} + +function reportHasFindings( + report: ReportOption, + findingsByTask: Record<string, ComparableFinding[]>, +): boolean { + const fromApi = Number(report.findings) + if (fromApi > 0) return true + return (findingsByTask[report.task_id]?.length ?? 0) > 0 +} + +/** Compare uses finding diffs; include ready reports and failed scans that still produced findings. */ +function comparableReports( + rows: ReportOption[], + findingsByTask: Record<string, ComparableFinding[]>, +): ReportOption[] { + return rows.filter( + (r) => r.status === 'ready' || (r.status === 'failed' && reportHasFindings(r, findingsByTask)), + ) +} + +function reportOptionLabel(report: ReportOption): string { + const statusNote = report.status === 'failed' ? ' (scan failed)' : '' + return `${report.name}${statusNote} — ${formatDateLong(report.generated_at)}` +} + +const severityChip: Record<string, string> = { + critical: 'bg-rag-red text-black', + high: 'bg-rag-amber text-black', + medium: 'bg-rag-blue text-black', + low: 'bg-charcoal-dark text-silver-bright border border-silver-bright/15', + info: 'bg-charcoal-dark text-silver border border-silver/15', +} + +function toComparableFinding(raw: Record<string, unknown>): ComparableFinding | null { + const title = typeof raw.title === 'string' ? raw.title : '' + const target = typeof raw.target === 'string' ? raw.target : '' + const category = typeof raw.category === 'string' ? raw.category : '' + const severity = typeof raw.severity === 'string' ? raw.severity : 'info' + if (!title && !target) return null + return { + id: typeof raw.id === 'string' ? raw.id : undefined, + title: title || 'Untitled finding', + target: target || 'Unknown target', + category: category || 'General', + severity, + description: typeof raw.description === 'string' ? raw.description : undefined, + } +} + +function FindingRow({ item, showBaseline, showComparison }: { + item: ComparedFinding + showBaseline?: boolean + showComparison?: boolean +}) { + const finding = (showComparison ? item.comparison : item.baseline) ?? item.comparison ?? item.baseline + if (!finding) return null + const severity = (finding.severity || 'info').toLowerCase() + const chip = severityChip[severity] ?? severityChip.info + + return ( + <div className="border-2 border-black bg-charcoal-dark/50 p-4 space-y-2"> + <div className="flex flex-wrap items-center gap-2"> + <span className={`px-2 py-0.5 text-[9px] font-black uppercase border-2 border-black ${chip}`}> + {severity} + </span> + <span className="text-[9px] font-mono text-silver/40 uppercase">{finding.category}</span> + </div> + <p className="text-sm font-black text-silver-bright uppercase tracking-tight">{finding.title}</p> + <p className="text-[10px] font-mono text-silver/50">{finding.target}</p> + {showBaseline && showComparison && item.baseline && item.comparison && ( + <p className="text-[9px] font-black uppercase tracking-widest text-rag-amber"> + {item.baseline.severity} → {item.comparison.severity} + </p> + )} + </div> + ) +} + +function CompareSection({ + title, + items, + tone, + showBaseline, + showComparison, +}: { + title: string + items: ComparedFinding[] + tone: string + showBaseline?: boolean + showComparison?: boolean +}) { + return ( + <section className="space-y-4"> + <div className="flex items-center justify-between border-b-2 border-black pb-2"> + <h3 className={`text-lg font-black uppercase tracking-tighter italic ${tone}`}>{title}</h3> + <span className="text-[10px] font-mono text-silver/40">{items.length}</span> + </div> + {items.length === 0 ? ( + <p className="text-[10px] font-black uppercase tracking-widest text-silver/30 italic">None</p> + ) : ( + <div className="space-y-3 max-h-80 overflow-y-auto pr-1"> + {items.map((item) => ( + <FindingRow + key={item.fingerprint} + item={item} + showBaseline={showBaseline} + showComparison={showComparison} + /> + ))} + </div> + )} + </section> + ) +} + +export default function ReportCompare() { + const [reports, setReports] = useState<ReportOption[]>([]) + const [findingsByTask, setFindingsByTask] = useState<Record<string, ComparableFinding[]>>({}) + const [baselineReportId, setBaselineReportId] = useState('') + const [comparisonReportId, setComparisonReportId] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState<string | null>(null) + + const loadData = () => { + setLoading(true) + setError(null) + Promise.all([getReports(), getFindings()]) + .then((results) => { + const reportData = results[0] as { reports?: ReportOption[] } + const findingsData = results[1] as { findings?: Record<string, unknown>[] } + + const byTask: Record<string, ComparableFinding[]> = {} + for (const raw of findingsData.findings || []) { + const row = raw as Record<string, unknown> + const finding = toComparableFinding(row) + if (!finding) continue + const taskId = typeof row.task_id === 'string' ? row.task_id : '' + if (taskId) { + if (!byTask[taskId]) byTask[taskId] = [] + byTask[taskId].push(finding) + } + } + setFindingsByTask(byTask) + + const rawReports = (reportData.reports || []).map((r) => ({ + ...r, + findings: Number(r.findings ?? 0), + })) + setReports(comparableReports(rawReports, byTask)) + }) + .catch(() => setError('Failed to load reports or findings')) + .finally(() => setLoading(false)) + } + + useEffect(() => { + loadData() + }, []) + + const baselineReport = reports.find((r) => r.id === baselineReportId) + const comparisonReport = reports.find((r) => r.id === comparisonReportId) + + const comparison: ReportComparisonResult | null = useMemo(() => { + if (!baselineReport || !comparisonReport) return null + if (baselineReport.id === comparisonReport.id) return null + const baselineFindings = findingsByTask[baselineReport.task_id] || [] + const comparisonFindings = findingsByTask[comparisonReport.task_id] || [] + return compareFindings(baselineFindings, comparisonFindings) + }, [baselineReport, comparisonReport, findingsByTask]) + + const sameReportSelected = Boolean( + baselineReportId && comparisonReportId && baselineReportId === comparisonReportId, + ) + + return ( + <div className="min-h-screen bg-charcoal-dark text-silver p-6 md:p-12 space-y-10"> + <header className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 pb-10 border-b-4 border-silver-bright/10"> + <div className="space-y-4"> + <Link + to={routes.reports} + className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-silver/50 hover:text-silver-bright" + > + <HugeiconsIcon icon={ArrowLeft02Icon} size={16} /> + Back to reports + </Link> + <div className="bg-rag-blue text-black px-4 py-1 text-xs uppercase tracking-widest inline-block shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] font-black"> + Report_Diff v1.0 + </div> + <h1 className="text-5xl md:text-7xl text-silver-bright uppercase tracking-tighter leading-none italic font-black"> + Compare <span className="text-rag-amber">Reports</span> + </h1> + <p className="text-sm font-mono text-silver/40 uppercase tracking-widest italic"> + BASELINE_VS_COMPARISON // NEW_FIXED_UNCHANGED_SEVERITY + </p> + </div> + <button + onClick={loadData} + className="bg-charcoal border-4 border-black p-4 text-silver-bright shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 transition-all" + title="Refresh" + > + <HugeiconsIcon icon={Refresh01Icon} size={20} /> + </button> + </header> + + {loading && ( + <p className="text-[10px] font-black uppercase tracking-[0.4em] text-silver/30 animate-pulse"> + Loading comparison data... + </p> + )} + + {!loading && error && ( + <div className="border-4 border-rag-red bg-rag-red/10 p-6 text-rag-red text-[10px] font-black uppercase tracking-widest"> + {error} + </div> + )} + + {!loading && !error && ( + <> + <section className="grid grid-cols-1 md:grid-cols-2 gap-8"> + <label className="space-y-3 block"> + <span className="text-[10px] font-black uppercase tracking-widest text-silver-bright"> + Baseline report (older) + </span> + <select + value={baselineReportId} + onChange={(e) => setBaselineReportId(e.target.value)} + className="w-full bg-charcoal border-4 border-black p-4 text-sm font-mono text-silver-bright" + > + <option value="">Select baseline...</option> + {reports.map((r) => ( + <option key={r.id} value={r.id}> + {reportOptionLabel(r)} + </option> + ))} + </select> + </label> + <label className="space-y-3 block"> + <span className="text-[10px] font-black uppercase tracking-widest text-silver-bright"> + Comparison report (newer) + </span> + <select + value={comparisonReportId} + onChange={(e) => setComparisonReportId(e.target.value)} + className="w-full bg-charcoal border-4 border-black p-4 text-sm font-mono text-silver-bright" + > + <option value="">Select comparison...</option> + {reports.map((r) => ( + <option key={r.id} value={r.id}> + {reportOptionLabel(r)} + </option> + ))} + </select> + </label> + </section> + + {reports.length < 2 && ( + <p className="text-[10px] font-black uppercase tracking-widest text-silver/40"> + At least two reports with findings are required to compare. Run scans that finish + with results, then refresh. + </p> + )} + + {sameReportSelected && ( + <p className="text-[10px] font-black uppercase tracking-widest text-rag-amber"> + Select two different reports to compare. + </p> + )} + + {comparison && ( + <motion.div + initial={{ opacity: 0, y: 12 }} + animate={{ opacity: 1, y: 0 }} + className="border-4 border-black bg-charcoal p-8 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] space-y-10" + > + <div className="flex items-center gap-3 text-silver-bright"> + <HugeiconsIcon icon={Analytics02Icon} size={28} /> + <div> + <p className="text-[10px] font-black uppercase tracking-widest text-silver/40">Diff ready</p> + <p className="text-sm font-mono"> + {baselineReport?.name} → {comparisonReport?.name} + </p> + </div> + </div> + + <div className="grid grid-cols-1 lg:grid-cols-2 gap-10"> + <CompareSection + title="New findings" + items={comparison.newFindings} + tone="text-rag-red" + showComparison + /> + <CompareSection + title="Fixed findings" + items={comparison.fixedFindings} + tone="text-rag-green" + showBaseline + /> + <CompareSection + title="Unchanged" + items={comparison.unchangedFindings} + tone="text-silver-bright" + showComparison + /> + <CompareSection + title="Severity changed" + items={comparison.severityChanged} + tone="text-rag-amber" + showBaseline + showComparison + /> + </div> + </motion.div> + )} + + {!comparison && baselineReportId && comparisonReportId && !sameReportSelected && ( + <p className="text-[10px] font-black uppercase tracking-widest text-silver/40"> + No findings to compare for the selected reports. + </p> + )} + </> + )} + </div> + ) +} diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 7464dbde5..38435f62b 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react' import { motion, AnimatePresence } from 'framer-motion' -import { useNavigate } from 'react-router-dom' +import { useNavigate, Link } from 'react-router-dom' +import { routes } from '../routes' import { HugeiconsIcon } from '@hugeicons/react' import { Analytics02Icon, @@ -15,6 +16,7 @@ import { UserShield02Icon, } from '@hugeicons/core-free-icons' import { getDashboardSummary, getReports, API_BASE } from '../api' +import { usePreferredExportFormat } from '../hooks/usePreferredExportFormat' import { formatDateLong, isWithinDateRange, type DateRange } from '../utils/date' type Report = { @@ -63,6 +65,29 @@ function ReportIcon({ return <HugeiconsIcon icon={icon} size={size} strokeWidth={1.9} className={className} /> } +function normalizeReport(raw: Partial<Report> & Record<string, unknown>): Report { + const findings = Number(raw.findings ?? 0) + const pages = Number(raw.pages ?? 0) + const assets = Number(raw.assets ?? 0) + const type = raw.type === 'executive' || raw.type === 'compliance' ? raw.type : 'technical' + const status = + raw.status === 'ready' || raw.status === 'generating' || raw.status === 'failed' + ? raw.status + : 'ready' + + return { + id: String(raw.id ?? ''), + task_id: String(raw.task_id ?? ''), + name: String(raw.name ?? 'Untitled report'), + type, + generated_at: String(raw.generated_at ?? ''), + status, + findings: Number.isFinite(findings) ? findings : 0, + assets: Number.isFinite(assets) ? assets : 0, + pages: Number.isFinite(pages) ? pages : 0, + } +} + export default function Reports() { const navigate = useNavigate() const [reports, setReports] = useState<Report[]>([]) @@ -72,7 +97,7 @@ export default function Reports() { const [selectedDateRange, setSelectedDateRange] = useState<DateRange>('all') const [loading, setLoading] = useState(true) const [error, setError] = useState<string | null>(null) - const [preferredFormat, setPreferredFormat] = useState<string | null>(null) + const { preferred: preferredFormat, savePreference } = usePreferredExportFormat() const latestReadyReport = [...reports] .filter((report) => report.status === 'ready') .sort((a, b) => new Date(b.generated_at).getTime() - new Date(a.generated_at).getTime())[0] @@ -82,7 +107,8 @@ export default function Reports() { setError(null) Promise.all([getReports(), getDashboardSummary()]) .then(([reportData, summaryData]: any) => { - setReports(reportData.reports || []) + const rows = Array.isArray(reportData?.reports) ? reportData.reports : [] + setReports(rows.map((row: Record<string, unknown>) => normalizeReport(row))) setSummary(summaryData || {}) }) .catch(() => { @@ -95,8 +121,6 @@ export default function Reports() { useEffect(() => { fetchReports() - const pref = localStorage.getItem('secuscan:preferred-export-format') - if (pref) setPreferredFormat(pref) }, []) const filteredReports = reports.filter((report) => @@ -122,13 +146,20 @@ export default function Reports() { </div> <div className="flex items-center gap-6"> + <Link + to={routes.reportsCompare} + className="bg-rag-blue border-4 border-black px-6 py-4 text-[9px] font-black uppercase tracking-widest text-black shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 transition-all" + > + Compare Reports + </Link> <button onClick={() => { if (!latestReadyReport) return - window.open(`${API_BASE}/task/${latestReadyReport.task_id}/report/pdf`, '_blank') + const format = preferredFormat ?? 'pdf' + window.open(`${API_BASE}/task/${latestReadyReport.task_id}/report/${format}`, '_blank') }} - aria-label="Download latest ready report PDF" - title={latestReadyReport ? 'Download latest ready report PDF' : 'No ready report available'} + aria-label={`Download latest ready report ${(preferredFormat ?? 'pdf').toUpperCase()}`} + title={latestReadyReport ? `Download latest ready report ${(preferredFormat ?? 'pdf').toUpperCase()}` : 'No ready report available'} disabled={!latestReadyReport} className="bg-charcoal border-4 border-black p-4 text-silver-bright shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed" > @@ -371,11 +402,7 @@ export default function Reports() { key={format} onClick={() => { if (report.status === 'generating') return - // Persist preferred export format for future sessions/tests - try { - localStorage.setItem('secuscan:preferred-export-format', format) - } catch {} - setPreferredFormat(format) + savePreference(format) window.open(`${API_BASE}/task/${report.task_id}/report/${format}`, '_blank') }} disabled={report.status === 'generating'} diff --git a/frontend/src/pages/Scans.tsx b/frontend/src/pages/Scans.tsx index 0e93cd903..143319a70 100644 --- a/frontend/src/pages/Scans.tsx +++ b/frontend/src/pages/Scans.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; -import { API_BASE, deleteTask, clearAllTasks, bulkDeleteTasks } from "../api"; +import { API_BASE, deleteTask, clearAllTasks, bulkDeleteTasks, startTask, ExecutionContext } from "../api"; import { routePath } from "../routes"; import { parseDateSafe, @@ -22,8 +22,10 @@ interface Task { started_at?: string; completed_at?: string; duration_seconds?: number; + error_message?: string; inputs?: any; preset?: string; + execution_context?: ExecutionContext; queue_position?: number; pending_count?: number; } @@ -168,17 +170,13 @@ export default function Scans() { async function handleRescan(task: Task) { try { - const res = await fetch(`${API_BASE}/task/start`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - plugin_id: task.plugin_id, - inputs: task.inputs || {}, - consent_granted: true, - preset: task.preset, - }), - }); - const data = await res.json(); + const data = await startTask( + task.plugin_id, + task.inputs || {}, + true, + task.preset, + task.execution_context, + ); if (data.task_id) { navigate(routePath.task(data.task_id)); } @@ -496,6 +494,50 @@ export default function Scans() { </div> </div> + {/* Error Notification Panel */} + {task.status === "failed" && task.error_message && (() => { + const isTimeoutFailure = + task.error_message?.toLowerCase().includes("timeout") || + task.error_message?.toLowerCase().includes("timed out"); + return ( + <div + className={`mt-6 border-4 border-black p-5 shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] ${ + isTimeoutFailure + ? "bg-rag-amber/10 border-l-rag-amber" + : "bg-rag-red/10 border-l-rag-red" + }`} + onClick={(e) => e.stopPropagation()} + > + <div className="flex items-start gap-4"> + <span + className={`material-symbols-outlined text-lg shrink-0 mt-0.5 ${ + isTimeoutFailure ? "text-rag-amber" : "text-rag-red" + }`} + > + {isTimeoutFailure ? "timer_off" : "error"} + </span> + <div className="space-y-2 min-w-0"> + <p + className={`text-[10px] font-black uppercase tracking-[0.3em] ${ + isTimeoutFailure ? "text-rag-amber" : "text-rag-red" + }`} + > + {isTimeoutFailure ? "SCAN_TIMEOUT_DETECTED" : "SCAN_FAILED"} + </p> + {isTimeoutFailure && ( + <p className="text-[10px] font-mono text-silver/60 uppercase tracking-widest"> + This scan exceeded its execution limit and was terminated automatically. + </p> + )} + <p className="text-[10px] font-mono text-silver/50 break-words"> + {task.error_message} + </p> + </div> + </div> + </div> + ); + })()} + {/* Expandable Details Block */} <AnimatePresence> {expandedId === task.task_id && ( @@ -505,63 +547,65 @@ export default function Scans() { exit={{ height: 0, opacity: 0 }} className="overflow-hidden" > - <div className="mt-12 pt-12 border-t-4 border-black grid grid-cols-1 md:grid-cols-3 gap-12 bg-charcoal-dark/20 -mx-8 -mb-8 p-8 border-dashed"> - <div className="space-y-4"> - <h5 className="text-[10px] font-black text-silver-bright uppercase tracking-[0.3em] italic flex items-center gap-3"> - <span className="w-1.5 h-3 bg-rag-blue"></span>{" "} - Signal_Metadata - </h5> - <div className="space-y-2"> - <p className="text-[10px] font-mono text-silver/40"> - PLUGIN:{" "} - <span className="text-silver-bright uppercase"> - {task.plugin_id} - </span> - </p> - {task.status === 'running' && task.scan_phase && ( - <p className="text-[10px] font-mono text-rag-blue/80 uppercase tracking-widest"> - PHASE: {task.scan_phase.replace(/_/g, ' ')} + <div className="mt-12 pt-12 border-t-4 border-black flex flex-wrap items-center justify-between gap-4 w-full bg-charcoal-dark/20 -mx-8 -mb-8 p-8 border-dashed"> + <div className="flex flex-wrap items-start gap-4"> + <div className="space-y-4"> + <h5 className="text-[10px] font-black text-silver-bright uppercase tracking-[0.3em] italic flex items-center gap-3"> + <span className="w-1.5 h-3 bg-rag-blue"></span>{" "} + Signal_Metadata + </h5> + <div className="space-y-2"> + <p className="text-[10px] font-mono text-silver/40"> + PLUGIN:{" "} + <span className="text-silver-bright uppercase"> + {task.plugin_id} + </span> </p> - )} - <p className="text-[10px] font-mono text-silver/40"> - SESSION:{" "} - <span className="text-silver-bright uppercase"> - ENCRYPTED_VTX - </span> - </p> + {task.status === 'running' && task.scan_phase && ( + <p className="text-[10px] font-mono text-rag-blue/80 uppercase tracking-widest"> + PHASE: {task.scan_phase.replace(/_/g, ' ')} + </p> + )} + <p className="text-[10px] font-mono text-silver/40"> + SESSION:{" "} + <span className="text-silver-bright uppercase"> + ENCRYPTED_VTX + </span> + </p> + </div> </div> - </div> - <div className="space-y-4"> - <h5 className="text-[10px] font-black text-silver-bright uppercase tracking-[0.3em] italic flex items-center gap-3"> - <span className="w-1.5 h-3 bg-rag-amber"></span>{" "} - Time_Matrix - </h5> - <div className="grid grid-cols-2 gap-4"> - <div className="space-y-1"> - <span className="text-[8px] text-silver/20 uppercase font-black tracking-widest"> - In_Lock - </span> - <span className="text-[10px] font-mono text-silver-bright block"> - {startDate - ? formatLocaleTime(startDate) - : "PENDING"} - </span> - </div> - <div className="space-y-1"> - <span className="text-[8px] text-silver/20 uppercase font-black tracking-widest"> - Release - </span> - <span className="text-[10px] font-mono text-silver-bright block"> - {endDate - ? formatLocaleTime(endDate) - : "N/A"} - </span> + <div className="space-y-4"> + <h5 className="text-[10px] font-black text-silver-bright uppercase tracking-[0.3em] italic flex items-center gap-3"> + <span className="w-1.5 h-3 bg-rag-amber"></span>{" "} + Time_Matrix + </h5> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-1"> + <span className="text-[8px] text-silver/20 uppercase font-black tracking-widest"> + In_Lock + </span> + <span className="text-[10px] font-mono text-silver-bright block"> + {startDate + ? formatLocaleTime(startDate) + : "PENDING"} + </span> + </div> + <div className="space-y-1"> + <span className="text-[8px] text-silver/20 uppercase font-black tracking-widest"> + Release + </span> + <span className="text-[10px] font-mono text-silver-bright block"> + {endDate + ? formatLocaleTime(endDate) + : "N/A"} + </span> + </div> </div> </div> </div> - <div className="flex items-center justify-end gap-6"> + <div className="flex flex-wrap items-center gap-3"> {(task.status === "completed" || task.status === "failed" || task.status === "cancelled") && ( diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 89a38bf1d..52c93af4e 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2,7 +2,19 @@ import React, { useState, useEffect } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { useTheme } from '../components/ThemeContext' import { useToast } from '../components/ToastContext' -import { getStoredApiKey, setStoredApiKey } from '../api' +import { + createNotificationRule, + deleteNotificationRule, + getStoredApiKey, + listNotificationHistory, + listNotificationRules, + setStoredApiKey, + updateNotificationRule, + type NotificationChannelType, + type NotificationHistoryRow, + type NotificationRule, + type NotificationSeverityThreshold, +} from '../api' import { ConfirmModal } from '../components/ConfirmModal' function getSystemThemeForSettings(): string { @@ -46,6 +58,152 @@ export default function Settings() { const [apiKey, setApiKey] = useState(() => getStoredApiKey() ?? '') const [apiKeyVisible, setApiKeyVisible] = useState(false) + const [notificationRules, setNotificationRules] = useState<NotificationRule[]>([]) + const [notificationRulesLoading, setNotificationRulesLoading] = useState(false) + const [notificationRulesError, setNotificationRulesError] = useState<string | null>(null) + const [notificationHistory, setNotificationHistory] = useState<Record<string, NotificationHistoryRow[]>>({}) + const [notificationHistoryLoading, setNotificationHistoryLoading] = useState<Record<string, boolean>>({}) + + const [newRule, setNewRule] = useState<{ + name: string + severity_threshold: NotificationSeverityThreshold + channel_type: NotificationChannelType + target_url_or_email: string + is_active: boolean + }>({ + name: '', + severity_threshold: 'high', + channel_type: 'webhook', + target_url_or_email: '', + is_active: true, + }) + + const [editRules, setEditRules] = useState<Record<string, Partial<NotificationRule>>>({}) + + async function refreshNotificationRules() { + setNotificationRulesLoading(true) + setNotificationRulesError(null) + try { + const rules = await listNotificationRules() + setNotificationRules(rules) + } catch { + setNotificationRulesError('Failed to load notification rules') + } finally { + setNotificationRulesLoading(false) + } + } + + async function submitNewRule() { + const trimmedName = newRule.name.trim() + const trimmedTarget = newRule.target_url_or_email.trim() + if (!trimmedName) { + addToast('Rule name is required', 'error') + return + } + if (!trimmedTarget) { + addToast('Target (webhook URL or email) is required', 'error') + return + } + try { + await createNotificationRule({ + ...newRule, + name: trimmedName, + target_url_or_email: trimmedTarget, + }) + addToast('Notification rule created', 'success') + setNewRule((prev) => ({ ...prev, name: '', target_url_or_email: '' })) + await refreshNotificationRules() + } catch { + addToast('Failed to create notification rule', 'error') + } + } + + function startEditRule(rule: NotificationRule) { + setEditRules((prev) => ({ + ...prev, + [rule.id]: { ...rule }, + })) + } + + function cancelEditRule(ruleId: string) { + setEditRules((prev) => { + const next = { ...prev } + delete next[ruleId] + return next + }) + } + + async function saveEditRule(ruleId: string) { + const draft = editRules[ruleId] + if (!draft) return + const name = String(draft.name ?? '').trim() + const target = String(draft.target_url_or_email ?? '').trim() + if (!name) { + addToast('Rule name is required', 'error') + return + } + if (!target) { + addToast('Target is required', 'error') + return + } + try { + await updateNotificationRule(ruleId, { + name, + target_url_or_email: target, + severity_threshold: draft.severity_threshold as NotificationSeverityThreshold, + channel_type: draft.channel_type as NotificationChannelType, + is_active: Boolean(draft.is_active), + }) + addToast('Notification rule updated', 'success') + cancelEditRule(ruleId) + await refreshNotificationRules() + } catch { + addToast('Failed to update notification rule', 'error') + } + } + + async function toggleRuleActive(rule: NotificationRule) { + try { + await updateNotificationRule(rule.id, { is_active: !rule.is_active }) + setNotificationRules((prev) => + prev.map((r) => (r.id === rule.id ? { ...r, is_active: !r.is_active } : r)), + ) + } catch { + addToast('Failed to update rule', 'error') + } + } + + async function removeRule(ruleId: string) { + setModalState({ + isOpen: true, + title: 'Delete rule', + message: 'Delete this notification rule? This cannot be undone.', + type: 'danger', + onConfirm: async () => { + try { + await deleteNotificationRule(ruleId) + addToast('Notification rule deleted', 'info') + await refreshNotificationRules() + } catch { + addToast('Failed to delete notification rule', 'error') + } finally { + setModalState(prev => ({ ...prev, isOpen: false })) + } + }, + }) + } + + async function loadRuleHistory(ruleId: string) { + setNotificationHistoryLoading((prev) => ({ ...prev, [ruleId]: true })) + try { + const data = await listNotificationHistory({ rule_id: ruleId, limit: 10, offset: 0 }) + setNotificationHistory((prev) => ({ ...prev, [ruleId]: data.history })) + } catch { + addToast('Failed to load notification history', 'error') + } finally { + setNotificationHistoryLoading((prev) => ({ ...prev, [ruleId]: false })) + } + } const handleSaveApiKey = () => { const trimmed = apiKey.trim() @@ -102,6 +260,10 @@ export default function Settings() { } }, []) + useEffect(() => { + refreshNotificationRules() + }, []) + const handleSave = () => { localStorage.setItem('secuscan-config', JSON.stringify(config)) addToast("Operational parameters synchronized", "success") @@ -130,7 +292,9 @@ export default function Settings() { message: "CRITICAL: THIS WILL PURGE ALL HISTORY AND ASSETS. PROCEED?", type: "danger", onConfirm: () => { - localStorage.clear() + Object.keys(localStorage) + .filter(key => key.startsWith('secuscan') || key === 'sidebar-expanded') + .forEach(key => localStorage.removeItem(key)) window.location.reload() setModalState(prev => ({ ...prev, isOpen: false })) } @@ -182,9 +346,11 @@ export default function Settings() { </div> ) - const Toggle = ({ checked, onChange, label, description }: any) => ( + const Toggle = ({ checked, onChange, label, description, ariaLabel }: any) => ( <button + type="button" onClick={() => onChange(!checked)} + aria-label={ariaLabel ?? label} className={`flex items-center justify-between p-8 bg-charcoal border-4 border-black transition-all group hover:shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] hover:-translate-y-0.5 ${ checked ? 'shadow-[4px_4px_0px_0px_rgba(0,0,0,1)]' : 'shadow-none' }`} @@ -279,6 +445,256 @@ export default function Settings() { </div> </section> + <section className="space-y-8" aria-label="Notification rules"> + <div className="flex items-center gap-4"> + <h3 className="text-xs font-black text-silver-bright uppercase tracking-[0.4em] italic">Notification_Rules</h3> + <div className="h-0.5 flex-1 bg-black/10"></div> + <button + type="button" + onClick={refreshNotificationRules} + className="px-4 py-2 bg-charcoal border-4 border-black text-[10px] font-black uppercase tracking-widest text-silver/60 hover:text-silver-bright hover:bg-black/40 transition-all" + aria-label="Refresh notification rules" + > + Refresh + </button> + </div> + + <div className="bg-charcoal border-4 border-black p-10 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] space-y-8"> + <div className="space-y-2"> + <label className="text-[10px] font-black text-silver-bright uppercase tracking-widest block italic">Create_Rule</label> + <p className="text-[10px] text-silver/40 uppercase font-bold italic mb-4 leading-relaxed"> + Configure alerts for high-risk findings via webhook or email placeholder. + </p> + </div> + + <div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> + <div className="bg-charcoal-dark border-4 border-black p-6 space-y-4"> + <label className="text-[10px] font-black text-silver/50 uppercase tracking-widest">Rule_Name</label> + <input + value={newRule.name} + onChange={(e) => setNewRule((p) => ({ ...p, name: e.target.value }))} + className="w-full bg-black/40 border-4 border-black p-4 text-xs font-mono text-silver-bright font-bold focus:outline-none focus:border-rag-blue/50 transition-colors" + placeholder="High risk webhook" + aria-label="New rule name" + /> + </div> + <div className="bg-charcoal-dark border-4 border-black p-6 space-y-4"> + <label className="text-[10px] font-black text-silver/50 uppercase tracking-widest">Target</label> + <input + value={newRule.target_url_or_email} + onChange={(e) => setNewRule((p) => ({ ...p, target_url_or_email: e.target.value }))} + className="w-full bg-black/40 border-4 border-black p-4 text-xs font-mono text-silver-bright font-bold focus:outline-none focus:border-rag-blue/50 transition-colors" + placeholder={newRule.channel_type === 'webhook' ? 'https://example.com/hook' : 'alerts@example.com'} + aria-label="New rule target" + /> + </div> + <div className="bg-charcoal-dark border-4 border-black p-6 space-y-4"> + <label className="text-[10px] font-black text-silver/50 uppercase tracking-widest">Severity_Threshold</label> + <select + value={newRule.severity_threshold} + onChange={(e) => setNewRule((p) => ({ ...p, severity_threshold: e.target.value as NotificationSeverityThreshold }))} + className="w-full bg-black/40 border-4 border-black p-4 text-xs font-mono text-rag-blue font-bold focus:outline-none focus:border-rag-blue/50 transition-colors uppercase appearance-none" + aria-label="New rule severity threshold" + > + {(['critical','high','medium','low','info'] as NotificationSeverityThreshold[]).map((s) => ( + <option key={s} value={s} className="bg-charcoal text-silver-bright">{s}</option> + ))} + </select> + </div> + <div className="bg-charcoal-dark border-4 border-black p-6 space-y-4"> + <label className="text-[10px] font-black text-silver/50 uppercase tracking-widest">Channel</label> + <select + value={newRule.channel_type} + onChange={(e) => setNewRule((p) => ({ ...p, channel_type: e.target.value as NotificationChannelType }))} + className="w-full bg-black/40 border-4 border-black p-4 text-xs font-mono text-rag-blue font-bold focus:outline-none focus:border-rag-blue/50 transition-colors uppercase appearance-none" + aria-label="New rule channel type" + > + {(['webhook','email'] as NotificationChannelType[]).map((c) => ( + <option key={c} value={c} className="bg-charcoal text-silver-bright">{c}</option> + ))} + </select> + </div> + </div> + + <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6"> + <Toggle + checked={newRule.is_active} + onChange={(val: boolean) => setNewRule((p) => ({ ...p, is_active: val }))} + label="Active" + description="RULE_ENABLED" + /> + <button + type="button" + onClick={submitNewRule} + className="bg-rag-blue text-black px-10 py-4 text-[10px] font-black uppercase tracking-[0.35em] shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] hover:shadow-none hover:translate-x-1 hover:translate-y-1 transition-all italic" + > + CREATE_RULE + </button> + </div> + </div> + + <div className="space-y-4"> + {notificationRulesLoading && ( + <p className="text-[10px] font-black uppercase tracking-widest text-silver/30">Loading rules...</p> + )} + {notificationRulesError && ( + <div className="border-4 border-rag-red bg-rag-red/10 p-6 text-rag-red text-[10px] font-black uppercase tracking-widest"> + {notificationRulesError} + </div> + )} + {!notificationRulesLoading && !notificationRulesError && notificationRules.length === 0 && ( + <p className="text-[10px] font-black uppercase tracking-widest text-silver/30 italic">No rules yet.</p> + )} + + {notificationRules.map((rule) => { + const draft = editRules[rule.id] + const isEditing = Boolean(draft) + const row = (draft ?? rule) as NotificationRule + const history = notificationHistory[rule.id] ?? [] + const histLoading = Boolean(notificationHistoryLoading[rule.id]) + return ( + <div key={rule.id} className="bg-charcoal border-4 border-black p-8 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)] space-y-6"> + <div className="flex flex-col lg:flex-row gap-6 lg:items-start lg:justify-between"> + <div className="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4"> + <div className="space-y-2"> + <label className="text-[10px] font-black text-silver/50 uppercase tracking-widest">Name</label> + <input + value={row.name} + disabled={!isEditing} + onChange={(e) => setEditRules((p) => ({ ...p, [rule.id]: { ...(p[rule.id] ?? {}), name: e.target.value } }))} + className={`w-full border-4 border-black p-3 text-xs font-mono font-bold focus:outline-none transition-colors ${ + isEditing ? 'bg-black/40 text-silver-bright focus:border-rag-blue/50' : 'bg-black/20 text-silver/60' + }`} + aria-label={`Rule name ${rule.id}`} + /> + </div> + <div className="space-y-2"> + <label className="text-[10px] font-black text-silver/50 uppercase tracking-widest">Target</label> + <input + value={row.target_url_or_email} + disabled={!isEditing} + onChange={(e) => setEditRules((p) => ({ ...p, [rule.id]: { ...(p[rule.id] ?? {}), target_url_or_email: e.target.value } }))} + className={`w-full border-4 border-black p-3 text-xs font-mono font-bold focus:outline-none transition-colors ${ + isEditing ? 'bg-black/40 text-silver-bright focus:border-rag-blue/50' : 'bg-black/20 text-silver/60' + }`} + aria-label={`Rule target ${rule.id}`} + /> + </div> + <div className="space-y-2"> + <label className="text-[10px] font-black text-silver/50 uppercase tracking-widest">Severity</label> + <select + value={row.severity_threshold} + disabled={!isEditing} + onChange={(e) => setEditRules((p) => ({ ...p, [rule.id]: { ...(p[rule.id] ?? {}), severity_threshold: e.target.value } }))} + className={`w-full border-4 border-black p-3 text-xs font-mono font-bold focus:outline-none transition-colors uppercase appearance-none ${ + isEditing ? 'bg-black/40 text-rag-blue focus:border-rag-blue/50' : 'bg-black/20 text-silver/60' + }`} + aria-label={`Rule severity ${rule.id}`} + > + {(['critical','high','medium','low','info'] as NotificationSeverityThreshold[]).map((s) => ( + <option key={s} value={s} className="bg-charcoal text-silver-bright">{s}</option> + ))} + </select> + </div> + <div className="space-y-2"> + <label className="text-[10px] font-black text-silver/50 uppercase tracking-widest">Channel</label> + <select + value={row.channel_type} + disabled={!isEditing} + onChange={(e) => setEditRules((p) => ({ ...p, [rule.id]: { ...(p[rule.id] ?? {}), channel_type: e.target.value } }))} + className={`w-full border-4 border-black p-3 text-xs font-mono font-bold focus:outline-none transition-colors uppercase appearance-none ${ + isEditing ? 'bg-black/40 text-rag-blue focus:border-rag-blue/50' : 'bg-black/20 text-silver/60' + }`} + aria-label={`Rule channel ${rule.id}`} + > + {(['webhook','email'] as NotificationChannelType[]).map((c) => ( + <option key={c} value={c} className="bg-charcoal text-silver-bright">{c}</option> + ))} + </select> + </div> + </div> + + <div className="space-y-3 shrink-0"> + <Toggle + checked={rule.is_active} + onChange={() => toggleRuleActive(rule)} + label={rule.is_active ? 'Active' : 'Inactive'} + description="TOGGLE_RULE" + ariaLabel={`Toggle rule ${rule.id}`} + /> + </div> + </div> + + <div className="flex flex-wrap items-center gap-3"> + {!isEditing ? ( + <> + <button + type="button" + onClick={() => startEditRule(rule)} + className="px-6 py-3 bg-charcoal-dark border-4 border-black text-[10px] font-black uppercase tracking-widest text-silver/60 hover:text-silver-bright hover:bg-black/40 transition-all" + > + Edit + </button> + <button + type="button" + onClick={() => removeRule(rule.id)} + className="px-6 py-3 bg-rag-red border-4 border-black text-[10px] font-black uppercase tracking-widest text-black hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] transition-all" + > + Delete + </button> + </> + ) : ( + <> + <button + type="button" + onClick={() => saveEditRule(rule.id)} + className="px-6 py-3 bg-rag-blue border-4 border-black text-[10px] font-black uppercase tracking-widest text-black hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] transition-all" + > + Save + </button> + <button + type="button" + onClick={() => cancelEditRule(rule.id)} + className="px-6 py-3 bg-charcoal-dark border-4 border-black text-[10px] font-black uppercase tracking-widest text-silver/60 hover:text-silver-bright hover:bg-black/40 transition-all" + > + Cancel + </button> + </> + )} + + <button + type="button" + onClick={() => loadRuleHistory(rule.id)} + className="px-6 py-3 bg-charcoal-dark border-4 border-black text-[10px] font-black uppercase tracking-widest text-silver/60 hover:text-silver-bright hover:bg-black/40 transition-all" + aria-label={`Load history ${rule.id}`} + > + {histLoading ? 'Loading history…' : 'Load history'} + </button> + </div> + + {history.length > 0 && ( + <div className="border-t-4 border-black/20 pt-6 space-y-3"> + <p className="text-[10px] font-black uppercase tracking-widest text-silver/40">Recent history</p> + <div className="space-y-2"> + {history.map((h) => ( + <div key={h.id} className="flex flex-col md:flex-row md:items-center md:justify-between gap-2 bg-black/20 border-2 border-black p-3"> + <div className="text-[10px] font-mono text-silver/60"> + <span className="text-silver-bright">{h.status}</span> · finding {h.finding_id} + </div> + <div className="text-[10px] font-mono text-silver/40"> + {h.error_message ? `error: ${h.error_message}` : ''} {h.sent_at ? `· ${h.sent_at}` : ''} + </div> + </div> + ))} + </div> + </div> + )} + </div> + ) + })} + </div> + </section> + <section className="space-y-8"> <div className="flex items-center gap-4"> <h3 className="text-xs font-black text-silver-bright uppercase tracking-[0.4em] italic">Engine_Parameters</h3> @@ -350,6 +766,7 @@ export default function Settings() { <select value={config.theme} onChange={(e) => setConfig({ ...config, theme: e.target.value })} + aria-label="Visual spectrum theme" className="w-full bg-black/40 border-4 border-black p-4 text-xs font-mono text-silver-bright focus:outline-none focus:ring-2 focus:ring-rag-blue" > <option value="dark" className="bg-charcoal text-silver-bright">Dark (Obsidian)</option> diff --git a/frontend/src/pages/TaskDetails.tsx b/frontend/src/pages/TaskDetails.tsx index 012f00ac2..2daf86f0f 100644 --- a/frontend/src/pages/TaskDetails.tsx +++ b/frontend/src/pages/TaskDetails.tsx @@ -12,7 +12,7 @@ import { Pdf02Icon, Refresh01Icon, } from '@hugeicons/core-free-icons' -import { API_BASE, getPluginSchema, getTaskResult, getTaskStatus, PluginFieldSchema, PluginSchemaResponse, startTask } from '../api' +import { API_BASE, getPluginSchema, getTaskResult, getTaskStatus, PluginFieldSchema, PluginSchemaResponse, startTask, ExecutionContext } from '../api' import { useTaskSubscription } from '../hooks/useTaskSubscription' import { routes, routePath } from '../routes' import { parseDateSafe, formatDateLong, formatLocaleTime } from '../utils/date' @@ -45,6 +45,7 @@ interface Task { error_message?: string inputs?: Record<string, any> preset?: string + execution_context?: ExecutionContext queue_position?: number pending_count?: number } @@ -61,6 +62,8 @@ interface RiskFactor { interface Finding { id?: string + finding_group_id?: string + asset_id?: string title: string category: string severity: string @@ -76,9 +79,61 @@ interface Finding { risk_factors?: RiskFactor[] exploitability?: number confidence?: number + validated?: boolean + validation_method?: string + confidence_reason?: string + evidence?: Array<Record<string, any>> + asset_refs?: string[] + finding_kind?: string + occurrence_count?: number + corroborating_sources?: string[] + evidence_count?: number + analyst_status?: string + retest_status?: string + first_seen_at?: string + last_seen_at?: string + service_fingerprint?: string + cpe?: string + references?: Array<Record<string, any>> asset_exposure?: string } +interface FindingGroup { + id: string + title: string + severity: string + category?: string + finding_kind?: string + confidence?: number + validated?: boolean + occurrence_count?: number + evidence_count?: number + analyst_status?: string + corroborating_sources?: string[] + latest_finding_id?: string +} + +interface AssetSummaryEntry { + asset_id: string + label?: string + target?: string + finding_count?: number + validated_count?: number + highest_severity?: string + services?: Array<Record<string, any>> +} + +interface ScanDiff { + new?: FindingGroup[] + resolved?: FindingGroup[] + changed?: Array<{ group_id: string; before: Finding; after: Finding }> + summary?: { + new_count?: number + resolved_count?: number + changed_count?: number + } +} + interface TaskResult { task_id: string plugin_id: string @@ -87,9 +142,13 @@ interface TaskResult { timestamp: string duration_seconds?: number status: string + execution_context?: ExecutionContext summary?: string[] severity_counts?: Record<string, number> findings?: Finding[] + finding_groups?: FindingGroup[] + asset_summary?: AssetSummaryEntry[] + scan_diff?: ScanDiff structured?: { rows?: Array<Record<string, any>> [key: string]: any @@ -200,6 +259,8 @@ export default function TaskDetails() { const { addToast } = useToast() const [task, setTask] = useState<Task | null>(null) + const isMounted = useRef(true) + const loadTaskSeqRef = useRef(0) const [result, setResult] = useState<TaskResult | null>(null) const [schema, setSchema] = useState<PluginSchemaResponse | null>(null) const [rawOutput, setRawOutput] = useState<string>('') @@ -213,9 +274,31 @@ export default function TaskDetails() { const [rawSearch, setRawSearch] = useState('') const [wrapRawOutput, setWrapRawOutput] = useState(true) + // ✅ FIX: isMounted cleanup belongs here in TaskDetails, not in FindingDrawer. + // The ref is owned by this component — its teardown must live here too. + useEffect(() => { + return () => { + isMounted.current = false + } + }, []) + + const copyTaskId = async () => { + if (!taskId) return + + try { + await navigator.clipboard.writeText(taskId) + addToast('Task ID copied to clipboard', 'success') + } catch (error) { + console.error('Failed to copy task ID:', error) + addToast('Failed to copy task ID', 'error') + } + } + const FindingDrawer = ({ finding, onClose }: { finding: Finding, onClose: () => void }) => { const drawerRef = useRef<HTMLDivElement>(null) + // ✅ FIX: Only focus + keydown logic here. No isMounted teardown — + // that ref belongs to TaskDetails and must not be touched by a child component. useEffect(() => { drawerRef.current?.focus() @@ -265,6 +348,11 @@ export default function TaskDetails() { <div className="space-y-4"> <h3 className="text-[10px] font-black text-silver/30 uppercase tracking-[0.3em] pb-2 border-b border-white/5">Description</h3> <p className="text-sm leading-8 text-silver/85 whitespace-pre-wrap">{finding.description}</p> + {finding.confidence_reason && ( + <p className="text-[11px] font-mono uppercase tracking-[0.12em] text-silver/45"> + {finding.confidence_reason} + </p> + )} </div> {finding.proof && ( @@ -312,6 +400,20 @@ export default function TaskDetails() { <p className="text-sm font-mono text-rag-blue/80 underline cursor-pointer">{finding.cve}</p> </div> )} + {(finding.validated !== undefined || finding.validation_method) && ( + <div className="space-y-4"> + <h3 className="text-[10px] font-black text-silver/30 uppercase tracking-[0.3em] pb-2 border-b border-white/5">Validation</h3> + <p className="text-sm font-mono text-silver-bright uppercase"> + {finding.validated ? 'Validated' : finding.validation_method || 'Unvalidated'} + </p> + </div> + )} + {finding.cpe && ( + <div className="space-y-4"> + <h3 className="text-[10px] font-black text-silver/30 uppercase tracking-[0.3em] pb-2 border-b border-white/5">CPE</h3> + <p className="text-[11px] font-mono text-silver-bright break-all">{finding.cpe}</p> + </div> + )} <div className="space-y-4"> <h3 className="text-[10px] font-black text-silver/30 uppercase tracking-[0.36em] pb-2 border-b border-white/5">Detected At</h3> <p className="text-xs text-silver/60 font-mono italic"> @@ -320,6 +422,19 @@ export default function TaskDetails() { </div> </div> + {finding.evidence && finding.evidence.length > 0 && ( + <div className="space-y-4"> + <h3 className="text-[10px] font-black text-silver/30 uppercase tracking-[0.3em] pb-2 border-b border-white/5">Structured Evidence</h3> + <div className="space-y-2"> + {finding.evidence.slice(0, 6).map((item, index) => ( + <div key={`${finding.id ?? finding.title}-evidence-${index}`} className="bg-black/25 border border-white/5 p-4 text-[11px] font-mono text-silver/80 break-words"> + {String(item.type || 'evidence').toUpperCase()}: {String(item.value ?? '')} + </div> + ))} + </div> + </div> + )} + {finding.risk_factors && finding.risk_factors.length > 0 && ( <div className="space-y-4"> <h3 className="text-[10px] font-black text-silver/30 uppercase tracking-[0.3em] pb-2 border-b border-white/5">Risk Factor Breakdown</h3> @@ -364,80 +479,86 @@ export default function TaskDetails() { loadTask() }, [taskId]) + const handleRescan = async () => { + if (!task || !taskId) return + + try { + const nextTask = await startTask( + task.plugin_id, + task.inputs || {}, + true, + task.preset, + task.execution_context + ) + + if (!isMounted.current) return + + addToast('Rescan started successfully', 'success') + navigate(routePath.task(nextTask.task_id)) + } catch (error) { + console.error('Failed to start rescan:', error) + if (isMounted.current) { + addToast('Failed to start rescan', 'error') + } + } + } + useTaskSubscription({ taskId: taskId!, onStatus: (status) => { + if (!isMounted.current) return setTask((prev: Task | null) => prev ? { ...prev, status } : null) if (['completed', 'failed', 'cancelled'].includes(status)) { loadTask() } }, onPhase: (phase) => { + if (!isMounted.current) return setScanPhase(phase) }, onOutput: (chunk) => { + if (!isMounted.current) return setRawOutput((prev) => prev + chunk) }, }) async function loadTask() { + const seq = ++loadTaskSeqRef.current + if (!isMounted.current) return + try { setError(null) const [statusData, resultData] = await Promise.all([ getTaskStatus(taskId!) as Promise<Task>, getTaskResult(taskId!).catch(() => null) as Promise<TaskResult | null> ]) + + if (seq !== loadTaskSeqRef.current || !isMounted.current) return + setTask(statusData) if (statusData.scan_phase) { setScanPhase(statusData.scan_phase) } - getPluginSchema(statusData.plugin_id).then(setSchema).catch(() => setSchema(null)) - if (resultData) { - // The backend returns the result fields at the top level + getPluginSchema(statusData.plugin_id) + .then(schema => isMounted.current && setSchema(schema)) + .catch(() => isMounted.current && setSchema(null)) + + if (resultData && isMounted.current) { setResult(resultData) - // Use the full output if available if (resultData.raw_output) { setRawOutput(resultData.raw_output) } } } catch (err) { - console.error('Failed to load task:', err) - setError(err instanceof Error ? err.message : 'Unable to load task details') - } finally { - setLoading(false) - } - } - - const copyTaskId = async () => { - if (!taskId) { - addToast('No Task ID available', 'warning') - return - } - try { - await navigator.clipboard.writeText(taskId || '') - addToast('Task ID copied successfully', 'success') - } catch (err) { - console.error('Failed to copy task ID:', err) - addToast('Unable to copy Task ID', 'error') - } - } - const handleRescan = async () => { - if (!task) return - try { - setLoading(true) - const res = await startTask( - task.plugin_id, - task.inputs || {}, - true, // Assuming consent was already granted for previous task - task.preset - ) - navigate(routePath.task(res.task_id)) - } catch (err) { - console.error('Rescan failed:', err) - // Error handling UI can go here + if (isMounted.current && seq === loadTaskSeqRef.current) { + console.error('Failed to load task:', err) + setError(err instanceof Error ? err.message : 'Unable to load task details') + } } finally { - setLoading(false) + if (isMounted.current && seq === loadTaskSeqRef.current) { + setLoading(false) + } } } @@ -472,7 +593,10 @@ export default function TaskDetails() { ) } - const findings = result?.structured?.findings || [] + const findings = result?.findings || result?.structured?.findings || [] + const findingGroups = result?.finding_groups || [] + const assetSummary = result?.asset_summary || [] + const scanDiff = result?.scan_diff || { new: [], resolved: [], changed: [], summary: { new_count: 0, resolved_count: 0, changed_count: 0 } } const tableRows = result?.structured?.rows || [] const summaryItems = result?.summary || [] const resultEntryCount = tableRows.length || findings.length @@ -489,7 +613,7 @@ export default function TaskDetails() { ? `${Math.floor(task.duration_seconds / 60)}M ${Math.floor(task.duration_seconds % 60)}S` : (task.status === 'completed' ? '0M 0S' : 'TERMINATED')) : 'ACTIVE' - const severityCounts = findings.reduce((acc: Record<string, number>, finding: any) => { + const severityCounts = result?.severity_counts || findings.reduce((acc: Record<string, number>, finding: any) => { const key = (finding.severity || 'info').toLowerCase() acc[key] = (acc[key] || 0) + 1 return acc @@ -579,6 +703,9 @@ export default function TaskDetails() { ['Path', parsedTarget?.pathname || '/'], ['Port', parsedTarget?.port || (parsedTarget?.protocol === 'https:' ? '443' : parsedTarget?.protocol === 'http:' ? '80' : 'N/A')], ['Findings', String(result?.structured?.total_count || findings.length).padStart(2, '0')], + ['Validation Mode', result?.execution_context?.validation_mode || task.execution_context?.validation_mode || 'N/A'], + ['Evidence Level', result?.execution_context?.evidence_level || task.execution_context?.evidence_level || 'N/A'], + ['Scan Profile', result?.execution_context?.scan_profile || task.execution_context?.scan_profile || 'N/A'], ...Object.entries(task.inputs || {}).map(([key, val]) => [formatKeyLabel(key), formatValue(val)] as [string, string]), ] const uniqueParameterEntries = Array.from( @@ -642,8 +769,6 @@ export default function TaskDetails() { setExpandedFindingRows(prev => ({ ...prev, [index]: !prev[index] })) } - - const DetailCard = ({ label, value, subValue }: { label: string, value: string, subValue?: string }) => ( <div className="bg-charcoal border border-white/5 p-5 shadow-[0_0_0_1px_rgba(255,255,255,0.02)] min-h-[118px] flex flex-col justify-between"> <div className="space-y-3"> @@ -688,7 +813,6 @@ export default function TaskDetails() { <span className="material-symbols-outlined text-sm"> content_copy </span> - Copy ID </button> <span className={`px-3 py-1 text-[10px] uppercase tracking-[0.3em] border ${statusTone}`}> @@ -931,6 +1055,73 @@ export default function TaskDetails() { <p className="text-sm text-silver/55 italic">No findings identified for this target profile.</p> )} </motion.div> + + <motion.div variants={itemVariants} className="grid grid-cols-1 xl:grid-cols-2 gap-6"> + <section className="border border-white/8 bg-charcoal p-6"> + <div className="flex items-center gap-4 mb-5"> + <h3 className="text-xs font-black text-silver-bright uppercase tracking-[0.36em] italic">Attack Surface Summary</h3> + <div className="h-px flex-1 bg-white/8" /> + <span className="text-[10px] uppercase tracking-[0.24em] text-silver/40">{assetSummary.length} Assets</span> + </div> + {assetSummary.length > 0 ? ( + <div className="space-y-3"> + {assetSummary.slice(0, 6).map((asset) => ( + <div key={asset.asset_id} className="border border-white/6 bg-black/20 p-4"> + <div className="flex items-center justify-between gap-4"> + <div> + <p className="text-xs font-black uppercase tracking-[0.18em] text-silver-bright break-all"> + {asset.label || asset.target || asset.asset_id} + </p> + <p className="mt-1 text-[10px] font-mono uppercase tracking-[0.16em] text-silver/35"> + {asset.services?.length || 0} services // {asset.finding_count || 0} findings // {asset.validated_count || 0} validated + </p> + </div> + <span className={`px-2 py-1 text-[9px] font-black uppercase tracking-[0.16em] border ${severityTone(asset.highest_severity)}`}> + {asset.highest_severity || 'info'} + </span> + </div> + </div> + ))} + </div> + ) : ( + <p className="text-sm text-silver/55 italic">No normalized asset summary is available for this scan.</p> + )} + </section> + + <section className="border border-white/8 bg-charcoal p-6"> + <div className="flex items-center gap-4 mb-5"> + <h3 className="text-xs font-black text-silver-bright uppercase tracking-[0.36em] italic">Scan Delta</h3> + <div className="h-px flex-1 bg-white/8" /> + <span className="text-[10px] uppercase tracking-[0.24em] text-silver/40">Previous vs Current</span> + </div> + <div className="grid grid-cols-3 gap-3 mb-4"> + {[ + ['New', scanDiff.summary?.new_count || 0, 'text-rag-red'], + ['Resolved', scanDiff.summary?.resolved_count || 0, 'text-rag-green'], + ['Changed', scanDiff.summary?.changed_count || 0, 'text-rag-blue'], + ].map(([label, value, tone]) => ( + <div key={String(label)} className="border border-white/6 bg-black/20 p-4 text-center"> + <p className="text-[9px] font-black uppercase tracking-[0.18em] text-silver/35">{label}</p> + <p className={`mt-2 text-3xl font-black italic ${tone}`}>{value}</p> + </div> + ))} + </div> + {(scanDiff.new?.length || 0) > 0 ? ( + <div className="space-y-2"> + {scanDiff.new!.slice(0, 4).map((group) => ( + <div key={group.id} className="border border-white/6 bg-black/20 p-3"> + <p className="text-xs font-black uppercase tracking-[0.16em] text-silver-bright">{group.title}</p> + <p className="mt-1 text-[10px] font-mono uppercase tracking-[0.16em] text-silver/35"> + {group.severity} // {group.finding_kind || 'observation'} // {(group.confidence || 0).toFixed(2)} confidence + </p> + </div> + ))} + </div> + ) : ( + <p className="text-sm text-silver/55 italic">No previous scan baseline is available yet for this target and scanner pairing.</p> + )} + </section> + </motion.div> </motion.section> )} @@ -943,6 +1134,52 @@ export default function TaskDetails() { exit="hidden" className="space-y-6" > + <motion.div variants={itemVariants} className="border border-white/8 bg-charcoal p-6"> + <div className="flex items-center gap-4 mb-5"> + <h3 className="text-xs font-black text-silver-bright uppercase tracking-[0.36em] italic">Finding Queue</h3> + <div className="h-px flex-1 bg-white/8" /> + <span className="text-[10px] uppercase tracking-[0.24em] text-silver/40">{findingGroups.length} Groups</span> + </div> + {findingGroups.length > 0 ? ( + <div className="space-y-3"> + {findingGroups.slice(0, 10).map((group) => ( + <button + key={group.id} + type="button" + onClick={() => { + const match = findings.find((finding: Finding) => finding.finding_group_id === group.id) || findings.find((finding: Finding) => finding.id === group.latest_finding_id) + if (match) setSelectedFinding(match) + }} + className="w-full border border-white/6 bg-black/20 p-4 text-left transition-colors hover:bg-white/[0.04]" + > + <div className="flex flex-wrap items-center gap-2"> + <span className={`px-2 py-1 text-[9px] font-black uppercase tracking-[0.16em] border ${severityTone(group.severity)}`}> + {group.severity} + </span> + <span className="border border-silver-bright/10 bg-charcoal-dark px-2 py-1 text-[9px] font-mono uppercase tracking-[0.15em] text-silver/70"> + {group.finding_kind || 'observation'} + </span> + <span className="border border-silver-bright/10 bg-charcoal-dark px-2 py-1 text-[9px] font-mono uppercase tracking-[0.15em] text-silver/70"> + {group.occurrence_count || 1} seen + </span> + {group.validated ? ( + <span className="border border-rag-green/20 bg-rag-green/10 px-2 py-1 text-[9px] font-mono uppercase tracking-[0.15em] text-rag-green"> + validated + </span> + ) : null} + </div> + <p className="mt-3 text-sm font-black uppercase tracking-[0.14em] text-silver-bright">{group.title}</p> + <p className="mt-1 text-[10px] font-mono uppercase tracking-[0.16em] text-silver/35"> + {(group.confidence || 0).toFixed(2)} confidence // {group.evidence_count || 0} evidence // {group.analyst_status || 'new'} + </p> + </button> + ))} + </div> + ) : ( + <p className="text-sm text-silver/55 italic">No grouped findings are available for this task.</p> + )} + </motion.div> + <motion.div variants={itemVariants} className="border border-white/8 bg-charcoal p-6"> <div className="flex items-center gap-4 mb-5"> <h3 className="text-xs font-black text-silver-bright uppercase tracking-[0.36em] italic">Discovery Results</h3> diff --git a/frontend/src/pages/ToolConfig.tsx b/frontend/src/pages/ToolConfig.tsx index 24bde3098..11c8dfb45 100644 --- a/frontend/src/pages/ToolConfig.tsx +++ b/frontend/src/pages/ToolConfig.tsx @@ -5,6 +5,13 @@ import { getPluginSchema, listPlugins, getSettings, + listTargetPolicies, + listCredentialProfiles, + listSessionProfiles, + ExecutionContext, + TargetPolicy, + CredentialProfile, + SessionProfile, PluginFieldSchema, PluginListItem, PluginSchemaResponse, @@ -64,6 +71,14 @@ export default function ToolConfig() { const [loading, setLoading] = useState(true) const [submitting, setSubmitting] = useState(false) const [serverLimits, setServerLimits] = useState<any | null>(null) + const [targetPolicies, setTargetPolicies] = useState<TargetPolicy[]>([]) + const [credentialProfiles, setCredentialProfiles] = useState<CredentialProfile[]>([]) + const [sessionProfiles, setSessionProfiles] = useState<SessionProfile[]>([]) + const [executionContext, setExecutionContext] = useState<ExecutionContext>({ + scan_profile: 'standard', + validation_mode: 'proof', + evidence_level: 'standard', + }) const fieldRefs = useRef<Record<string, HTMLElement | null>>({}) useEffect(() => { @@ -97,8 +112,19 @@ export default function ToolConfig() { setInputs(initialInputs) setConsentGranted(!matchedPlugin.requires_consent) try { - const s = await getSettings() + const [s, policies, credentials, sessions] = await Promise.all([ + getSettings(), + listTargetPolicies(), + listCredentialProfiles(), + listSessionProfiles(), + ]) setServerLimits(s || null) + if (s?.execution_context?.default) { + setExecutionContext(s.execution_context.default) + } + setTargetPolicies(policies.items || []) + setCredentialProfiles(credentials.items || []) + setSessionProfiles(sessions.items || []) } catch (e) { // non-fatal; default to null } @@ -132,11 +158,18 @@ export default function ToolConfig() { const invalidFieldCount = Object.keys(validationErrors).length const hasValidationErrors = invalidFieldCount > 0 const safetyLevel = String(schema?.safety?.level || 'safe') + const availability = plugin?.availability + const missingBinaries = availability?.missing_binaries ?? [] + const hasMissingBinaries = missingBinaries.length > 0 const handleFieldChange = (field: PluginFieldSchema, value: unknown) => { setInputs((prev) => ({ ...prev, [field.id]: value })) } + const handleExecutionContextChange = <K extends keyof ExecutionContext>(key: K, value: ExecutionContext[K]) => { + setExecutionContext((prev) => ({ ...prev, [key]: value })) + } + const handlePresetChange = (preset: string) => { if (!schema) return setSelectedPreset(preset) @@ -165,6 +198,7 @@ export default function ToolConfig() { inputs, plugin.requires_consent ? consentGranted : true, selectedPreset || undefined, + executionContext, ) addToast(`Task queued: ${plugin.name}`, 'success') navigate(routePath.task(task.task_id)) @@ -232,14 +266,14 @@ export default function ToolConfig() { </div> </header> - {plugin.availability.missing_binaries.length > 0 && ( + {hasMissingBinaries && ( <section className="bg-charcoal border-4 border-rag-amber p-6 shadow-[6px_6px_0px_0px_rgba(0,0,0,1)]"> <p className="text-[10px] uppercase font-black tracking-[0.3em] text-rag-amber"> Plugin unavailable </p> <p className="text-[10px] text-silver/70 uppercase tracking-widest mt-2 leading-relaxed"> - {plugin.availability.guidance || - `Unavailable: Requires external binaries (${plugin.availability.missing_binaries.join(', ')}). Install required tools locally to enable this scanner.`} + {availability?.guidance || + `Unavailable: Requires external binaries (${missingBinaries.join(', ')}). Install required tools locally to enable this scanner.`} </p> <p className="text-[9px] text-silver/40 uppercase tracking-widest mt-3"> Task launch remains available, but execution may fail until dependencies are installed. @@ -450,6 +484,84 @@ export default function ToolConfig() { ? `${invalidFieldCount} field${invalidFieldCount > 1 ? 's' : ''} need${invalidFieldCount === 1 ? 's' : ''} attention before scan start` : 'All fields valid'} </p> + <div className="space-y-4 border-4 border-black bg-charcoal p-5"> + <p className="text-[10px] text-silver-bright uppercase tracking-[0.3em] font-black">Execution Context</p> + <div className="space-y-2"> + <label className="text-[10px] text-silver/60 uppercase tracking-widest font-black">Target Policy</label> + <select + value={executionContext.target_policy_id ?? ''} + onChange={(event) => handleExecutionContextChange('target_policy_id', event.target.value || null)} + className="w-full bg-charcoal-dark border-4 border-black px-3 py-3 text-[11px] text-silver-bright uppercase tracking-widest" + > + <option value="">Default Local Policy</option> + {targetPolicies.map((policy) => ( + <option key={policy.id} value={policy.id}>{policy.name}</option> + ))} + </select> + </div> + <div className="space-y-2"> + <label className="text-[10px] text-silver/60 uppercase tracking-widest font-black">Scan Profile</label> + <select + value={executionContext.scan_profile} + onChange={(event) => handleExecutionContextChange('scan_profile', event.target.value)} + className="w-full bg-charcoal-dark border-4 border-black px-3 py-3 text-[11px] text-silver-bright uppercase tracking-widest" + > + <option value="standard">Standard Profile</option> + <option value="authenticated">Authenticated Profile</option> + <option value="aggressive">Aggressive Profile</option> + </select> + </div> + <div className="space-y-2"> + <label className="text-[10px] text-silver/60 uppercase tracking-widest font-black">Validation Mode</label> + <select + value={executionContext.validation_mode} + onChange={(event) => handleExecutionContextChange('validation_mode', event.target.value as ExecutionContext['validation_mode'])} + className="w-full bg-charcoal-dark border-4 border-black px-3 py-3 text-[11px] text-silver-bright uppercase tracking-widest" + > + <option value="detect_only">Detect Only</option> + <option value="proof">Proof</option> + <option value="controlled_extract">Controlled Extract</option> + </select> + </div> + <div className="space-y-2"> + <label className="text-[10px] text-silver/60 uppercase tracking-widest font-black">Evidence Level</label> + <select + value={executionContext.evidence_level} + onChange={(event) => handleExecutionContextChange('evidence_level', event.target.value as ExecutionContext['evidence_level'])} + className="w-full bg-charcoal-dark border-4 border-black px-3 py-3 text-[11px] text-silver-bright uppercase tracking-widest" + > + <option value="minimal">Minimal</option> + <option value="standard">Standard</option> + <option value="full">Full</option> + </select> + </div> + <div className="space-y-2"> + <label className="text-[10px] text-silver/60 uppercase tracking-widest font-black">Credential Profile</label> + <select + value={executionContext.credential_profile_id ?? ''} + onChange={(event) => handleExecutionContextChange('credential_profile_id', event.target.value || null)} + className="w-full bg-charcoal-dark border-4 border-black px-3 py-3 text-[11px] text-silver-bright uppercase tracking-widest" + > + <option value="">None</option> + {credentialProfiles.map((profile) => ( + <option key={profile.id} value={profile.id}>{profile.name}</option> + ))} + </select> + </div> + <div className="space-y-2"> + <label className="text-[10px] text-silver/60 uppercase tracking-widest font-black">Session Profile</label> + <select + value={executionContext.session_profile_id ?? ''} + onChange={(event) => handleExecutionContextChange('session_profile_id', event.target.value || null)} + className="w-full bg-charcoal-dark border-4 border-black px-3 py-3 text-[11px] text-silver-bright uppercase tracking-widest" + > + <option value="">None</option> + {sessionProfiles.map((profile) => ( + <option key={profile.id} value={profile.id}>{profile.name}</option> + ))} + </select> + </div> + </div> {plugin.requires_consent && ( <div className="space-y-4 border-4 border-black bg-charcoal p-5"> <p className="text-[10px] text-silver/60 uppercase tracking-widest leading-6"> diff --git a/frontend/src/pages/Workflows.tsx b/frontend/src/pages/Workflows.tsx index ec9371a7b..f982bfb51 100644 --- a/frontend/src/pages/Workflows.tsx +++ b/frontend/src/pages/Workflows.tsx @@ -21,7 +21,15 @@ const itemVariants = { visible: { opacity: 1, y: 0, transition: { duration: 0.3 } }, } -const emptySteps = [{ plugin_id: '', inputs: {} }] +const emptySteps = [{ + plugin_id: '', + inputs: {}, + execution_context: { + scan_profile: 'standard', + validation_mode: 'proof', + evidence_level: 'standard', + }, +}] function timeAgo(iso?: string | null) { if (!iso) return null diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 38caa98f4..eaa6bcd70 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -5,6 +5,7 @@ export const routes = { findings: '/findings', scans: '/scans', reports: '/reports', + reportsCompare: '/reports/compare', workflows: '/workflows', settings: '/settings', task: '/task/:taskId', diff --git a/frontend/src/utils/compareFindings.ts b/frontend/src/utils/compareFindings.ts new file mode 100644 index 000000000..a54448a7d --- /dev/null +++ b/frontend/src/utils/compareFindings.ts @@ -0,0 +1,84 @@ +export type ComparableFinding = { + id?: string + title: string + target: string + category: string + severity: string + description?: string +} + +export type ComparedFinding = { + fingerprint: string + baseline?: ComparableFinding + comparison?: ComparableFinding +} + +export type ReportComparisonResult = { + newFindings: ComparedFinding[] + fixedFindings: ComparedFinding[] + unchangedFindings: ComparedFinding[] + severityChanged: ComparedFinding[] +} + +export function findingFingerprint(finding: ComparableFinding): string { + const title = (finding.title || '').trim().toLowerCase() + const target = (finding.target || '').trim().toLowerCase() + const category = (finding.category || '').trim().toLowerCase() + return `${title}|${target}|${category}` +} + +function indexFindings(findings: ComparableFinding[]): Map<string, ComparableFinding> { + const map = new Map<string, ComparableFinding>() + for (const finding of findings) { + map.set(findingFingerprint(finding), finding) + } + return map +} + +export function compareFindings( + baselineFindings: ComparableFinding[], + comparisonFindings: ComparableFinding[], +): ReportComparisonResult { + const baseline = indexFindings(baselineFindings) + const comparison = indexFindings(comparisonFindings) + + const newFindings: ComparedFinding[] = [] + const fixedFindings: ComparedFinding[] = [] + const unchangedFindings: ComparedFinding[] = [] + const severityChanged: ComparedFinding[] = [] + + for (const [fingerprint, comparisonFinding] of comparison.entries()) { + const baselineFinding = baseline.get(fingerprint) + if (!baselineFinding) { + newFindings.push({ fingerprint, comparison: comparisonFinding }) + continue + } + + const baselineSeverity = (baselineFinding.severity || 'info').toLowerCase() + const comparisonSeverity = (comparisonFinding.severity || 'info').toLowerCase() + const entry: ComparedFinding = { + fingerprint, + baseline: baselineFinding, + comparison: comparisonFinding, + } + + if (baselineSeverity === comparisonSeverity) { + unchangedFindings.push(entry) + } else { + severityChanged.push(entry) + } + } + + for (const [fingerprint, baselineFinding] of baseline.entries()) { + if (!comparison.has(fingerprint)) { + fixedFindings.push({ fingerprint, baseline: baselineFinding }) + } + } + + return { + newFindings, + fixedFindings, + unchangedFindings, + severityChanged, + } +} diff --git a/frontend/testing/e2e/settings-persistence.spec.ts b/frontend/testing/e2e/settings-persistence.spec.ts new file mode 100644 index 000000000..9bab5949b --- /dev/null +++ b/frontend/testing/e2e/settings-persistence.spec.ts @@ -0,0 +1,52 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Settings persistence", () => { + + test("theme persists after reload", async ({ page }) => { + await page.goto("/"); + + await page.evaluate(() => { + localStorage.setItem( + "secuscan_api_key", + "6abeafd82cdb0eebea98dc3817b0bb5f9f8773f60402bccad9eaad7870ae8f58" + ); + }); + + await page.goto("/settings"); + + await page.waitForLoadState("networkidle"); + + const themeSelect = page.getByLabel(/visual spectrum theme/i); + + await themeSelect.selectOption("light"); + + await page.getByRole("button", { + name: /commit_engine_changes/i, + }).click(); + + await page.reload(); + + await expect( + page.getByLabel(/visual spectrum theme/i) + ).toHaveValue("light"); + }); + + test("theme reset path is available", async ({ page }) => { + await page.goto("/"); + + await page.evaluate(() => { + localStorage.setItem("secuscan_api_key", "6abeafd82cdb0eebea98dc3817b0bb5f9f8773f60402bccad9eaad7870ae8f58"); + }); + + await page.goto("/settings"); + + const resetButton = page.getByRole("button", { + name: /reset to system default/i, + }); + + await expect(resetButton).toBeVisible(); + + + }); + +}); \ No newline at end of file diff --git a/frontend/testing/e2e/workflow.spec.ts b/frontend/testing/e2e/workflow.spec.ts new file mode 100644 index 000000000..f5002b1e0 --- /dev/null +++ b/frontend/testing/e2e/workflow.spec.ts @@ -0,0 +1,151 @@ +import { test, expect } from '@playwright/test'; + +const BASE = 'http://127.0.0.1:5173'; +const API_KEY = '6abeafd82cdb0eebea98dc3817b0bb5f9f8773f60402bccad9eaad7870ae8f58'; + +const MOCK_WORKFLOWS = [ + { + id: 'wf-001', + name: 'Daily DNS Scan', + schedule_seconds: 86400, + enabled: true, + steps: [{ plugin_id: 'dns_recon', inputs: { target: 'example.com' } }], + last_run_at: new Date(Date.now() - 3600000).toISOString(), + queued_task_ids: [], + created_at: new Date().toISOString(), + }, +]; + +const MOCK_CREATED_WORKFLOW = { + id: 'wf-002', + name: 'Nightly Port Scan', + schedule_seconds: 43200, + enabled: true, + steps: [{ plugin_id: 'port_scanner', inputs: { target: '10.0.0.1' } }], + last_run_at: null, + queued_task_ids: [], + created_at: new Date().toISOString(), +}; + +const MOCK_RUN_RESPONSE = { + queued_task_ids: ['task-001', 'task-002'], +}; + +async function setupMocks(page: import('@playwright/test').Page) { + await page.route(`${BASE}/api/v1/workflows`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ workflows: MOCK_WORKFLOWS, total: 1 }), + }); + } else if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_CREATED_WORKFLOW), + }); + } else { + await route.fulfill({ status: 404 }); + } + }); + + await page.route(`${BASE}/api/v1/workflows/*/run`, async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(MOCK_RUN_RESPONSE), + }); + await page.route(`${BASE}/api/v1/workflows`, async (innerRoute) => { + const updated = MOCK_WORKFLOWS.map(w => ({ + ...w, + queued_task_ids: ['task-001', 'task-002'], + last_run_at: new Date().toISOString(), + })); + await innerRoute.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ workflows: updated, total: 1 }), + }); + }); + }); + + await page.route(`${BASE}/api/v1/workflows/*`, async (route) => { + if (route.request().method() === 'DELETE') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ deleted: true }), + }); + await page.route(`${BASE}/api/v1/workflows`, async (innerRoute) => { + await innerRoute.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ workflows: [], total: 0 }), + }); + }); + } else { + await route.fulfill({ status: 404 }); + } + }); +} + +test.describe('Workflow lifecycle', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate((key) => { + localStorage.setItem('secuscan_api_key', key); + }, API_KEY); + }); + + test('displays existing workflows', async ({ page }) => { + await setupMocks(page); + await page.goto('/workflows'); + await expect(page.getByRole('heading', { name: 'Workflows' })).toBeVisible(); + await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('Enabled')).toBeVisible(); + }); + + test('creates a new workflow via the create sheet', async ({ page }) => { + await setupMocks(page); + await page.goto('/workflows'); + await page.getByRole('button', { name: /new workflow/i }).click(); + await expect(page.getByRole('heading', { name: /new workflow/i })).toBeVisible(); + await page.getByPlaceholder('My Workflow').fill('Nightly Port Scan'); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByText('Nightly Port Scan')).toBeVisible({ timeout: 10000 }); + }); + + test('runs a workflow and shows queued tasks', async ({ page }) => { + await setupMocks(page); + await page.goto('/workflows'); + await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 }); + await page.getByTitle('Run now').click(); + await expect(page.getByText('task-001')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText('task-002')).toBeVisible(); + }); + + test('deletes a workflow and removes it from the grid', async ({ page }) => { + await setupMocks(page); + await page.goto('/workflows'); + await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 }); + await page.getByTitle('Delete').click(); + await expect(page.getByRole('heading', { name: /delete workflow/i })).toBeVisible(); + await page.getByRole('button', { name: 'Delete' }).click(); + await expect(page.getByText('No Workflows')).toBeVisible({ timeout: 10000 }); + }); + + test('full lifecycle: create, run, and delete a workflow', async ({ page }) => { + await setupMocks(page); + await page.goto('/workflows'); + await expect(page.getByText('Daily DNS Scan')).toBeVisible({ timeout: 10000 }); + await page.getByRole('button', { name: /new workflow/i }).click(); + await page.getByPlaceholder('My Workflow').fill('Nightly Port Scan'); + await page.getByRole('button', { name: 'Create' }).click(); + await expect(page.getByText('Nightly Port Scan')).toBeVisible({ timeout: 10000 }); + await page.getByTitle('Run now').first().click(); + await page.getByTitle('Delete').first().click(); + await page.getByRole('button', { name: 'Delete' }).click(); + await expect(page.getByText('No Workflows')).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/frontend/testing/unit/api.auth.test.ts b/frontend/testing/unit/api.auth.test.ts index 2ef7352f3..dc96372f0 100644 --- a/frontend/testing/unit/api.auth.test.ts +++ b/frontend/testing/unit/api.auth.test.ts @@ -30,7 +30,7 @@ function mockResponse(status: number, body: unknown = {}) { ok: status >= 200 && status < 300, status, json: () => Promise.resolve(body), - } as Response) + }) } // --------------------------------------------------------------------------- @@ -155,6 +155,47 @@ describe('request() 401 handling', () => { }) }) +// --------------------------------------------------------------------------- +// request() — timeout cleanup +// --------------------------------------------------------------------------- + +describe('request() timeout cleanup', () => { + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + it('clears the timeout when fetch rejects', async () => { + const timeoutId = 1 as unknown as ReturnType<typeof setTimeout> +vi.spyOn(window, 'setTimeout').mockImplementation(() => timeoutId) + const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout') + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fetch failed'))) + + await expect(listPlugins()).rejects.toThrow('fetch failed') + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId) + }) + + it('clears the timeout when request is aborted', async () => { + vi.useFakeTimers() + const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout') + + vi.stubGlobal('fetch', vi.fn().mockImplementation((_, init) => { + const signal = (init as any)?.signal + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + })) + + const promise = listPlugins() + vi.runAllTimers() + await expect(promise).rejects.toThrow('Aborted') + expect(clearTimeoutSpy).toHaveBeenCalled() + }) +}) + // --------------------------------------------------------------------------- // request() — successful authenticated request // --------------------------------------------------------------------------- diff --git a/frontend/testing/unit/api.request-behavior.test.ts b/frontend/testing/unit/api.request-behavior.test.ts new file mode 100644 index 000000000..4437ff65f --- /dev/null +++ b/frontend/testing/unit/api.request-behavior.test.ts @@ -0,0 +1,160 @@ +/** + * Frontend API request behavior tests. + * + * Covers: + * - Successful JSON responses + * - Non-OK HTTP responses + * - Timeout + abort behavior + * - Timeout cleanup + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getHealth, listPlugins } from '../../src/api' + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +function mockJsonResponse(status: number, body: unknown = {}) { + return Promise.resolve({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + } as Response) +} + +// ----------------------------------------------------------------------------- +// Shared cleanup +// ----------------------------------------------------------------------------- + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +// ----------------------------------------------------------------------------- +// Successful responses +// ----------------------------------------------------------------------------- + +describe('API request — successful responses', () => { + it('successfully parses JSON response', async () => { + const responseBody = { + plugins: [{ id: 'nmap', name: 'Nmap' }], + total: 1, + } + + vi.stubGlobal( + 'fetch', + vi.fn().mockReturnValue(mockJsonResponse(200, responseBody)), + ) + + const result = await listPlugins() + + expect(result).toEqual(responseBody) + }) + + it('clears timeout after successful response', async () => { + const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout') + + vi.stubGlobal( + 'fetch', + vi.fn().mockReturnValue(mockJsonResponse(200, { ok: true })), + ) + + await getHealth() + + expect(clearTimeoutSpy).toHaveBeenCalled() + }) +}) + +// ----------------------------------------------------------------------------- +// Non-OK HTTP responses +// ----------------------------------------------------------------------------- + +describe('API request — non-OK responses', () => { + it('throws on non-OK HTTP response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockReturnValue(mockJsonResponse(500)), + ) + + await expect(getHealth()).rejects.toThrow('Request failed: 500') + }) +}) + +// ----------------------------------------------------------------------------- +// Timeout + abort behavior +// ----------------------------------------------------------------------------- + +describe('API request — timeout behavior', () => { + it('sets a 10-second timeout', async () => { + const setTimeoutSpy = vi.spyOn(window, 'setTimeout') + + vi.stubGlobal( + 'fetch', + vi.fn().mockReturnValue(mockJsonResponse(200, {})), + ) + + await getHealth() + + expect(setTimeoutSpy).toHaveBeenCalledWith( + expect.any(Function), + 10000, + ) + }) + + it('passes AbortSignal to fetch', async () => { + const fetchSpy = vi + .fn() + .mockReturnValue(mockJsonResponse(200, {})) + + vi.stubGlobal('fetch', fetchSpy) + + await getHealth() + + const [, init] = fetchSpy.mock.calls[0] + + expect(init?.signal).toBeInstanceOf(AbortSignal) + }) + + it('aborts request when timeout expires', async () => { + vi.useFakeTimers() + + const fetchSpy = vi.fn().mockImplementation((_, init) => { + const signal = (init as RequestInit)?.signal + + return new Promise((_, reject) => { + signal?.addEventListener('abort', () => { + reject(new DOMException('Aborted', 'AbortError')) + }) + }) + }) + + vi.stubGlobal('fetch', fetchSpy) + + const promise = getHealth() + + vi.runAllTimers() + + await expect(promise).rejects.toThrow('Aborted') + }) + + it('cleans up timeout on fetch rejection', async () => { + vi.useFakeTimers() + + const setTimeoutSpy = vi.spyOn(window, 'setTimeout') + const clearTimeoutSpy = vi.spyOn(window, 'clearTimeout') + + vi.stubGlobal( + 'fetch', + vi.fn().mockRejectedValue(new Error('Network error')), + ) + + await getHealth().catch(() => {}) + + const timeoutId = setTimeoutSpy.mock.results[0].value + + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId) + }) +}) \ No newline at end of file diff --git a/frontend/testing/unit/components/AppShell.test.tsx b/frontend/testing/unit/components/AppShell.test.tsx new file mode 100644 index 000000000..996e6d6ad --- /dev/null +++ b/frontend/testing/unit/components/AppShell.test.tsx @@ -0,0 +1,243 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import AppShell from '../../../src/components/AppShell' + +vi.mock('../../../src/components/Sidebar', () => ({ + default: () => <aside data-testid="desktop-sidebar" />, +})) + +vi.mock('../../../src/components/Background', () => ({ + default: () => <div data-testid="background" />, +})) + +vi.mock('../../../src/hooks/useShortcuts', () => ({ + useShortcuts: vi.fn(), +})) + +const renderShell = (initialPath = '/') => + render( + <MemoryRouter initialEntries={[initialPath]}> + <Routes> + <Route + path="*" + element={ + <AppShell> + <section>Page content</section> + </AppShell> + } + /> + </Routes> + </MemoryRouter> + ) + +describe('AppShell', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('opens and closes the mobile drawer from the menu controls', async () => { + const user = userEvent.setup() + renderShell() + + await user.click(screen.getByRole('button', { name: /toggle navigation menu/i })) + + expect(screen.getByRole('link', { name: 'Settings' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /toggle navigation menu/i })) + + await waitFor(() => { + expect(screen.queryByRole('link', { name: 'Settings' })).not.toBeInTheDocument() + }) + }) + + it('closes the mobile drawer when the backdrop is clicked', async () => { + const user = userEvent.setup() + renderShell() + + await user.click(screen.getByRole('button', { name: /toggle navigation menu/i })) + expect(screen.getByRole('link', { name: 'Settings' })).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /close navigation menu/i })) + + await waitFor(() => { + expect(screen.queryByRole('link', { name: 'Settings' })).not.toBeInTheDocument() + }) + }) + + it('locks page scroll while the mobile drawer is open', async () => { + const user = userEvent.setup() + renderShell() + + expect(document.body.style.overflow).toBe('') + + await user.click(screen.getByRole('button', { name: /toggle navigation menu/i })) + expect(document.body.style.overflow).toBe('hidden') + + await user.click(screen.getByRole('button', { name: /close navigation menu/i })) + await waitFor(() => { + expect(document.body.style.overflow).toBe('') + }) + }) + + it('closes the mobile drawer when navigation changes routes', async () => { + const user = userEvent.setup() + renderShell() + + await user.click(screen.getByRole('button', { name: /toggle navigation menu/i })) + await user.click(screen.getByRole('link', { name: 'Settings' })) + + await waitFor(() => { + expect(screen.queryByRole('link', { name: 'Settings' })).not.toBeInTheDocument() + }) + expect(screen.getByText('Page content')).toBeInTheDocument() + }) + + it('renders the mobile bottom navigation path shortcuts', () => { + renderShell() + + expect(screen.getByRole('link', { name: /dashboard/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /scans/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /findings/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /reports/i })).toBeInTheDocument() + expect(screen.getByRole('link', { name: /workflows/i })).toBeInTheDocument() + }) +}) + + +describe('AppShell mobile navigation focus trap', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('exposes the drawer as an accessible dialog when open', async () => { + const user = userEvent.setup() + renderShell() + + await user.click(screen.getByRole('button', { name: /toggle navigation menu/i })) + + expect(screen.getByRole('dialog', { name: /navigation menu/i })).toBeInTheDocument() + }) + + it('closes the mobile menu when Escape is pressed', async () => { + const user = userEvent.setup() + renderShell() + + await user.click(screen.getByRole('button', { name: /toggle navigation menu/i })) + expect(screen.getByRole('dialog', { name: /navigation menu/i })).toBeInTheDocument() + + await user.keyboard('{Escape}') + + expect(screen.queryByRole('dialog', { name: /navigation menu/i })).not.toBeInTheDocument() + }) + + it('sets aria-expanded on the hamburger button when menu is open', async () => { + const user = userEvent.setup() + renderShell() + + const button = screen.getByRole('button', { name: /toggle navigation menu/i }) + expect(button).toHaveAttribute('aria-expanded', 'false') + + await user.click(button) + expect(button).toHaveAttribute('aria-expanded', 'true') + }) + + it('moves focus into the drawer when menu opens', async () => { + const user = userEvent.setup() + renderShell() + + await user.click(screen.getByRole('button', { name: /toggle navigation menu/i })) + + await waitFor(() => { + const dialog = screen.getByRole('dialog') + const firstFocusable = dialog.querySelector('a, button') + expect(document.activeElement).toBe(firstFocusable) + }) + }) + + it('returns focus to the hamburger button when menu closes via Escape', async () => { + const user = userEvent.setup() + renderShell() + + const button = screen.getByRole('button', { name: /toggle navigation menu/i }) + await user.click(button) + await user.keyboard('{Escape}') + + await waitFor(() => { + expect(document.activeElement).toBe(button) + }) + }) + + it('traps Tab key focus within the drawer', async () => { + const user = userEvent.setup() + renderShell() + + await user.click(screen.getByRole('button', { name: /toggle navigation menu/i })) + + const dialog = screen.getByRole('dialog') + const links = Array.from(dialog.querySelectorAll('a')) + expect(links.length).toBeGreaterThan(1) + + await waitFor(() => { + expect(document.activeElement).toBe(links[0]) + }) + + for (let i = 1; i < links.length; i++) { + await user.tab() + expect(document.activeElement).toBe(links[i]) + } + + await user.tab() + expect(document.activeElement).toBe(links[0]) + + await user.tab({ shift: true }) + expect(document.activeElement).toBe(links[links.length - 1]) + }) +}) + + +describe('sidebar state synchronization', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('responds to sidebar-state-changed custom event (same-tab)', async () => { + renderShell() + + const main = document.querySelector('main')! + // Default: expanded → --sidebar-width = 220px + expect(main.style.getPropertyValue('--sidebar-width')).toBe('220px') + + // Collapse via custom event + window.dispatchEvent(new CustomEvent('sidebar-state-changed', { detail: false })) + await waitFor(() => { + expect(main.style.getPropertyValue('--sidebar-width')).toBe('64px') + }) + + // Expand via custom event + window.dispatchEvent(new CustomEvent('sidebar-state-changed', { detail: true })) + await waitFor(() => { + expect(main.style.getPropertyValue('--sidebar-width')).toBe('220px') + }) + }) + + it('responds to storage event (cross-tab)', async () => { + renderShell() + + const main = document.querySelector('main')! + + // Simulate another tab writing to localStorage + localStorage.setItem('sidebar-expanded', 'false') + window.dispatchEvent(new Event('storage')) + await waitFor(() => { + expect(main.style.getPropertyValue('--sidebar-width')).toBe('64px') + }) + + localStorage.setItem('sidebar-expanded', 'true') + window.dispatchEvent(new Event('storage')) + await waitFor(() => { + expect(main.style.getPropertyValue('--sidebar-width')).toBe('220px') + }) + }) +}) \ No newline at end of file diff --git a/frontend/testing/unit/components/Pagination.test.tsx b/frontend/testing/unit/components/Pagination.test.tsx new file mode 100644 index 000000000..9c91808bd --- /dev/null +++ b/frontend/testing/unit/components/Pagination.test.tsx @@ -0,0 +1,62 @@ +import { render, screen } from "@testing-library/react"; +import Pagination from "../../../src/components/Pagination"; + +describe("Pagination", () => { + const defaultProps = { + page: 1, + total: 100, + limit: 10, + loading: false, + onPrev: vi.fn(), + onNext: vi.fn(), + }; + + // Range math tests + it("shows correct start and end for first page", () => { + render(<Pagination {...defaultProps} page={1} total={100} limit={10} />); + expect(screen.getByText("1–10")).toBeInTheDocument(); + }); + + it("shows correct start and end for middle page", () => { + render(<Pagination {...defaultProps} page={3} total={100} limit={10} />); + expect(screen.getByText("21–30")).toBeInTheDocument(); + }); + + it("shows correct end on last page with partial results", () => { + render(<Pagination {...defaultProps} page={5} total={42} limit={10} />); + expect(screen.getByText("41–42")).toBeInTheDocument(); + }); + + it("shows 0 start and 0 end when total is 0", () => { + render(<Pagination {...defaultProps} page={1} total={0} limit={10} />); + expect(screen.getByText("0–0")).toBeInTheDocument(); + }); + + // Disabled state tests + it("disables prev button on first page", () => { + render(<Pagination {...defaultProps} page={1} />); + expect(screen.getByText("Prev_Page").closest("button")).toBeDisabled(); + }); + + it("enables next button when not on last page", () => { + render(<Pagination {...defaultProps} page={1} total={100} limit={10} />); + expect(screen.getByText("Next_Page").closest("button")).not.toBeDisabled(); + }); + + it("disables next button on last page", () => { + render(<Pagination {...defaultProps} page={10} total={100} limit={10} />); + expect(screen.getByText("Next_Page").closest("button")).toBeDisabled(); + }); + + it("disables both buttons when total is 0", () => { + render(<Pagination {...defaultProps} page={1} total={0} limit={10} />); + expect(screen.getByText("Prev_Page").closest("button")).toBeDisabled(); + expect(screen.getByText("Next_Page").closest("button")).toBeDisabled(); + }); + + it("disables both buttons when loading", () => { + render(<Pagination {...defaultProps} loading={true} />); + expect(screen.getByText("Prev_Page").closest("button")).toBeDisabled(); + expect(screen.getByText("Next_Page").closest("button")).toBeDisabled(); + }); +}) \ No newline at end of file diff --git a/frontend/testing/unit/components/Sidebar.test.tsx b/frontend/testing/unit/components/Sidebar.test.tsx new file mode 100644 index 000000000..12d8a8af2 --- /dev/null +++ b/frontend/testing/unit/components/Sidebar.test.tsx @@ -0,0 +1,293 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import Sidebar from '../../../src/components/Sidebar'; + +/* ------------------------------------------------------------------ */ +/* Mocks */ +/* ------------------------------------------------------------------ */ + +// Mock framer-motion to render plain elements so tests focus on behavior, +// not animation internals. +vi.mock('framer-motion', async () => { + const ReactModule = await import('react'); + const createMotionProxy = () => + new Proxy( + {}, + { + get(_target: unknown, prop: string) { + return ReactModule.forwardRef((props: Record<string, unknown>, ref: React.Ref<unknown>) => { + // Strip framer-specific props so they don't leak to the DOM + const { + initial: _initial, + animate: _animate, + exit: _exit, + transition: _transition, + layoutId: _layoutId, + whileHover: _whileHover, + whileTap: _whileTap, + ...rest + } = props; + return ReactModule.createElement(prop, { ...rest, ref }); + }); + }, + }, + ); + + return { + motion: createMotionProxy(), + AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>, + }; +}); + +// Minimal ThemeToggle stub — the Sidebar imports it but its internals +// are not under test here. +vi.mock('../../../src/components/ThemeToggle', () => ({ + default: () => <div data-testid="theme-toggle">ThemeToggle</div>, +})); + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +/** + * Renders the Sidebar inside a MemoryRouter so NavLink can resolve + * active state based on `initialRoute`. + */ +function renderSidebar(initialRoute = '/') { + return render( + <MemoryRouter initialEntries={[initialRoute]}> + <Sidebar /> + </MemoryRouter>, + ); +} + +/* ------------------------------------------------------------------ */ +/* Tests */ +/* ------------------------------------------------------------------ */ + +describe('Sidebar', () => { + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + /* --------------------------------------------------------------- + * Collapse Persistence + * --------------------------------------------------------------- */ + describe('collapse persistence', () => { + it('defaults to expanded when localStorage has no saved value', () => { + renderSidebar(); + + // All nav labels should be visible when expanded + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Toolkit')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('restores collapsed state from localStorage', () => { + localStorage.setItem('sidebar-expanded', 'false'); + renderSidebar(); + + // When collapsed the aside should have the narrow width style + const aside = document.querySelector('aside'); + expect(aside).toBeInTheDocument(); + + // Labels should still render (AnimatePresence mock renders children), + // but the component reads the persisted value; verify localStorage was + // consumed correctly by checking that the toggle icon shows the + // "expand" arrow. + expect( + screen.getByText('keyboard_double_arrow_right'), + ).toBeInTheDocument(); + }); + + it('restores expanded state from localStorage', () => { + localStorage.setItem('sidebar-expanded', 'true'); + renderSidebar(); + + // Expanded state shows the "collapse" arrow + expect( + screen.getByText('keyboard_double_arrow_left'), + ).toBeInTheDocument(); + }); + + it('persists collapsed state to localStorage after toggling', async () => { + const user = userEvent.setup(); + renderSidebar(); + + // Initially expanded (default) + expect(localStorage.getItem('sidebar-expanded')).toBe('true'); + + // Click the sidebar to toggle collapse + await user.click(screen.getByRole('complementary')); + + expect(localStorage.getItem('sidebar-expanded')).toBe('false'); + }); + + it('persists expanded state when toggling back open', async () => { + const user = userEvent.setup(); + localStorage.setItem('sidebar-expanded', 'false'); + renderSidebar(); + + await user.click(screen.getByRole('complementary')); + + expect(localStorage.getItem('sidebar-expanded')).toBe('true'); + }); + + it('toggles via the dedicated toggle button', async () => { + const user = userEvent.setup(); + renderSidebar(); + + await user.click( + screen.getByRole('button', { name: 'keyboard_double_arrow_left' }), + ); + + // After clicking the toggle button, the sidebar should be collapsed + expect(localStorage.getItem('sidebar-expanded')).toBe('false'); + expect( + screen.getByText('keyboard_double_arrow_right'), + ).toBeInTheDocument(); + }); + }); + + /* --------------------------------------------------------------- + * Active Nav State + * --------------------------------------------------------------- */ + describe('active nav rendering', () => { + it('marks Dashboard link as active on "/"', () => { + renderSidebar('/'); + + const dashboardLink = screen.getByText('Dashboard').closest('a')!; + expect(dashboardLink).toHaveAttribute('href', '/'); + expect(dashboardLink).toHaveAttribute('aria-current', 'page'); + }); + + it('marks Toolkit link as active on "/toolkit"', () => { + renderSidebar('/toolkit'); + + const toolkitLink = screen.getByText('Toolkit').closest('a')!; + expect(toolkitLink).toHaveAttribute('href', '/toolkit'); + expect(toolkitLink).toHaveAttribute('aria-current', 'page'); + }); + + it('marks Settings link as active on "/settings"', () => { + renderSidebar('/settings'); + + const settingsLink = screen.getByText('Settings').closest('a')!; + expect(settingsLink).toHaveAttribute('href', '/settings'); + expect(settingsLink).toHaveAttribute('aria-current', 'page'); + }); + + it('marks Registry link as active on "/scans"', () => { + renderSidebar('/scans'); + + const scansLink = screen.getByText('Registry').closest('a')!; + expect(scansLink).toHaveAttribute('href', '/scans'); + expect(scansLink).toHaveAttribute('aria-current', 'page'); + }); + + it('does not mark non-active links with active styling', () => { + renderSidebar('/settings'); + + const dashboardLink = screen.getByText('Dashboard').closest('a')!; + expect(dashboardLink).not.toHaveAttribute('aria-current'); + }); + + it('renders active indicator elements for the active link', () => { + renderSidebar('/'); + + const dashboardLink = screen.getByText('Dashboard').closest('a')!; + // The active link contains the glow div (layoutId="activeGlow") and + // the side bar div (layoutId="activeBar") + const glowDiv = dashboardLink.querySelector('[class*="bg-rag-red/5"]'); + expect(glowDiv).toBeInTheDocument(); + }); + + it('does not render active indicator elements for inactive links', () => { + renderSidebar('/settings'); + + const dashboardLink = screen.getByText('Dashboard').closest('a')!; + // Inactive link should NOT contain the active glow element + const glowDivs = dashboardLink.querySelectorAll('[class*="bg-rag-red/5"]'); + expect(glowDivs.length).toBe(0); + }); + }); + + /* --------------------------------------------------------------- + * Highlighted Nav State + * --------------------------------------------------------------- */ + describe('highlighted nav rendering', () => { + it('applies highlight styling to the Toolkit link when not active', () => { + // Navigate to a route that is NOT /toolkit so Toolkit is highlighted + // but not active + renderSidebar('/'); + + const toolkitLink = screen.getByText('Toolkit').closest('a')!; + expect(toolkitLink.className).toContain('bg-rag-blue/15'); + expect(toolkitLink.className).toContain('border-rag-blue/30'); + }); + + it('applies highlight icon styling to the Toolkit icon when not active', () => { + renderSidebar('/'); + + // The Toolkit icon should show the highlighted (blue) text + const toolkitIcon = screen.getByText('add_circle'); + expect(toolkitIcon.className).toContain('text-rag-blue'); + }); + + it('does not apply highlight styling to non-highlight links', () => { + renderSidebar('/'); + + // Dashboard is active here so skip it; check Findings which is + // neither active nor highlighted + const findingsLink = screen.getByText('Findings').closest('a')!; + expect(findingsLink.className).not.toContain('bg-rag-blue/15'); + }); + + it('applies active styling instead of highlight when Toolkit is the active route', () => { + renderSidebar('/toolkit'); + + const toolkitLink = screen.getByText('Toolkit').closest('a')!; + expect(toolkitLink).toHaveAttribute('aria-current', 'page'); + // Highlight-specific classes should NOT be present + expect(toolkitLink.className).not.toContain('bg-rag-blue/15'); + }); + }); + + /* --------------------------------------------------------------- + * Nav Structure + * --------------------------------------------------------------- */ + describe('navigation structure', () => { + it('renders all expected navigation links', () => { + renderSidebar(); + + const expectedLabels = [ + 'Toolkit', + 'Dashboard', + 'Registry', + 'Findings', + 'Reports', + 'Workflows', + 'Settings', + ]; + + for (const label of expectedLabels) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it('renders section headers when expanded', () => { + renderSidebar(); + + expect(screen.getByText('Monitor')).toBeInTheDocument(); + expect(screen.getByText('Analyze')).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/testing/unit/components/ThemeToggle.test.tsx b/frontend/testing/unit/components/ThemeToggle.test.tsx new file mode 100644 index 000000000..e678b92d7 --- /dev/null +++ b/frontend/testing/unit/components/ThemeToggle.test.tsx @@ -0,0 +1,102 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, beforeEach } from 'vitest' +import ThemeToggle from '../../../src/components/ThemeToggle' +import { ThemeProvider } from '../../../src/components/ThemeContext' + +const STORAGE_KEY = 'secuscan-theme' + +function renderWithTheme() { + return render( + <ThemeProvider> + <ThemeToggle /> + </ThemeProvider>, + ) +} + +describe('ThemeToggle', () => { + beforeEach(() => { + localStorage.removeItem(STORAGE_KEY) + document.documentElement.classList.remove('dark', 'theme-light') + }) + + it('renders a button with an accessible label', () => { + renderWithTheme() + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-label') + }) + + it('toggles from dark to light on click and persists to localStorage', async () => { + localStorage.setItem(STORAGE_KEY, 'dark') + const user = userEvent.setup() + renderWithTheme() + + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-pressed', 'true') + + await user.click(button) + + expect(localStorage.getItem(STORAGE_KEY)).toBe('light') + expect(button).toHaveAttribute('aria-pressed', 'false') + }) + + it('toggles from light to dark on click and persists to localStorage', async () => { + localStorage.setItem(STORAGE_KEY, 'light') + const user = userEvent.setup() + renderWithTheme() + + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-pressed', 'false') + + await user.click(button) + + expect(localStorage.getItem(STORAGE_KEY)).toBe('dark') + expect(button).toHaveAttribute('aria-pressed', 'true') + }) + + it('aria-label reflects the target theme, not the current one', () => { + localStorage.setItem(STORAGE_KEY, 'dark') + renderWithTheme() + const button = screen.getByRole('button') + expect(button).toHaveAttribute('aria-label', 'Toggle to light mode') + }) + + it('shows dark_mode icon when theme is light', () => { + localStorage.setItem(STORAGE_KEY, 'light') + renderWithTheme() + expect(screen.getByText('dark_mode')).toBeTruthy() + }) + + it('shows light_mode icon when theme is dark', () => { + localStorage.setItem(STORAGE_KEY, 'dark') + renderWithTheme() + expect(screen.getByText('light_mode')).toBeTruthy() + }) + + it('stops click propagation', async () => { + localStorage.setItem(STORAGE_KEY, 'dark') + const user = userEvent.setup() + const parentHandler = vi.fn() + render( + <div onClick={parentHandler}> + <ThemeProvider> + <ThemeToggle /> + </ThemeProvider> + </div>, + ) + await user.click(screen.getByRole('button')) + expect(parentHandler).not.toHaveBeenCalled() + }) + + it('applies sm size classes when size prop is sm', () => { + render( + <ThemeProvider> + <ThemeToggle size="sm" /> + </ThemeProvider>, + ) + const button = screen.getByRole('button') + expect(button.className).toContain('w-9') + expect(button.className).toContain('h-9') + }) +}) diff --git a/frontend/testing/unit/hooks/usePreferredExportFormat.test.ts b/frontend/testing/unit/hooks/usePreferredExportFormat.test.ts new file mode 100644 index 000000000..6dc7cc0ba --- /dev/null +++ b/frontend/testing/unit/hooks/usePreferredExportFormat.test.ts @@ -0,0 +1,36 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { usePreferredExportFormat } from '../../../src/hooks/usePreferredExportFormat' + +const STORAGE_KEY = 'secuscan:preferred-export-format' + +describe('usePreferredExportFormat', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('starts with no preferred format when storage is empty', () => { + const { result } = renderHook(() => usePreferredExportFormat()) + + expect(result.current.preferred).toBeNull() + }) + + it('restores a stored preferred format on first render', () => { + localStorage.setItem(STORAGE_KEY, 'pdf') + + const { result } = renderHook(() => usePreferredExportFormat()) + + expect(result.current.preferred).toBe('pdf') + }) + + it('persists a newly selected preferred format', () => { + const { result } = renderHook(() => usePreferredExportFormat()) + + act(() => { + result.current.savePreference('csv') + }) + + expect(result.current.preferred).toBe('csv') + expect(localStorage.getItem(STORAGE_KEY)).toBe('csv') + }) +}) diff --git a/frontend/testing/unit/pages/Findings.test.tsx b/frontend/testing/unit/pages/Findings.test.tsx index 768e38a1f..5a23d8c85 100644 --- a/frontend/testing/unit/pages/Findings.test.tsx +++ b/frontend/testing/unit/pages/Findings.test.tsx @@ -1,594 +1,262 @@ -import { render, screen, waitFor, within, fireEvent } from '@testing-library/react' +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { MemoryRouter } from 'react-router-dom' +import { describe, it, expect, vi, beforeEach } from 'vitest' import Findings from '../../../src/pages/Findings' -import { getFindings } from '../../../src/api' -import * as dateUtils from '../../../src/utils/date' + +// ── Mocks ──────────────────────────────────────────────────────────────────── vi.mock('../../../src/api', () => ({ getFindings: vi.fn(), - API_BASE: 'http://127.0.0.1:8000', })) -vi.mock('../../../src/hooks/useSavedViews', async (importOriginal) => { - const actual = await importOriginal<typeof import('../../../src/hooks/useSavedViews')>() +vi.mock('../../../src/utils/date', async (importOriginal: any) => { + const actual = await importOriginal() as typeof import('../../../src/utils/date') return { ...actual, - useSavedViews: () => ({ - views: [], - loading: false, - saveView: vi.fn(), - deleteView: vi.fn(), - renameView: vi.fn(), - }), + formatLocaleDate: (d: any) => (d ? '2024-01-01' : ''), } }) -// ── Fixtures ────────────────────────────────────────────────────────────────── - -const criticalFinding = { - id: 'finding-crit-1', - severity: 'critical', - category: 'injection', - title: 'SQL Injection in Login', - target: 'api.example.com', - description: 'Parameterized queries not used.', - remediation: 'Use prepared statements.', - discovered_at: '2026-05-14T10:00:00Z', - cvss: 9.8, - cve: 'CVE-2026-1234', - plugin_id: 'sqlmap', +// @tanstack/react-virtual needs ResizeObserver + scrollHeight in jsdom +if (typeof global.ResizeObserver === 'undefined') { + global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } as any } -const highFinding = { - id: 'finding-high-1', - severity: 'high', - category: 'xss', - title: 'Stored XSS in Comments', - target: 'web.example.com', - description: 'User input rendered without escaping.', - remediation: 'Sanitize output.', - discovered_at: '2026-05-13T08:30:00Z', - cvss: 7.5, - plugin_id: 'zap', -} - -const mediumFinding = { - id: 'finding-med-1', - severity: 'medium', - category: 'misconfiguration', - title: 'Missing Security Headers', - target: 'api.example.com', - description: 'Several headers are absent.', - remediation: 'Add CSP and HSTS headers.', - discovered_at: '2026-05-15T14:00:00Z', - plugin_id: 'nikto', -} -const allFindings = [criticalFinding, highFinding, mediumFinding] +Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, value: 800 }) +Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 600 }) -// ── Helpers ─────────────────────────────────────────────────────────────────── +import { getFindings } from '../../../src/api' -function renderFindings() { - return render( - <MemoryRouter> - <Findings /> - </MemoryRouter>, - ) -} +// ── Fixtures ───────────────────────────────────────────────────────────────── -/** Wait for data to load by looking for a known finding title. */ -async function waitForLoad() { - await waitFor(() => { - expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) - }) +function makeFinding(overrides: Partial<ReturnType<typeof baseFinding>> = {}) { + return baseFinding(overrides) } -/** Helper to grab the sort select via its label. */ -function getSortSelect() { - const label = screen.getByText('Sort By') - return label.parentElement!.querySelector('select')! +function baseFinding(overrides: any = {}) { + return { + id: `f-${Math.random().toString(36).slice(2)}`, + severity: 'high', + category: 'Network', + title: 'Test Finding', + target: 'example.com', + description: 'A test description', + remediation: 'Fix it', + discovered_at: '2024-01-01T00:00:00Z', + cvss: 7.5, + cve: undefined, + ...overrides, + } } -/** Helper to collect visible finding titles from the list section. */ -function getVisibleTitles() { - // h3 tags in the list hold finding titles - return Array.from(document.querySelectorAll('h3')) - .map((el) => el.textContent ?? '') - .filter(Boolean) +function makeLargeDataset(count: number) { + return Array.from({ length: count }, (_, i) => + makeFinding({ + id: `finding-${i}`, + severity: ['critical', 'high', 'medium', 'low', 'info'][i % 5], + title: `Finding ${i}`, + }), + ) } -// ── Loading ─────────────────────────────────────────────────────────────────── - -describe('Findings — loading state', () => { - it('shows loading text while fetching', () => { - vi.mocked(getFindings).mockReturnValue(new Promise(() => {})) - renderFindings() - expect(screen.getByText(/Synchronizing findings feed/i)).toBeInTheDocument() - }) -}) - -// ── Severity filter ─────────────────────────────────────────────────────────── +// ── Tests ───────────────────────────────────────────────────────────────────── -describe('Findings — severity filtering', () => { +describe('Findings — virtualized list', () => { beforeEach(() => { - vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) - }) - - it('shows all findings by default', async () => { - renderFindings() - await waitForLoad() - expect(screen.getAllByText('Stored XSS in Comments').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Missing Security Headers').length).toBeGreaterThanOrEqual(1) - }) - - it('filters to critical only when critical pill is clicked', async () => { - const user = userEvent.setup() - renderFindings() - await waitForLoad() - - const critButtons = screen.getAllByRole('button', { name: /critical/i }) - const toggle = critButtons.find((btn) => btn.textContent?.includes('1')) - expect(toggle).toBeTruthy() - await user.click(toggle!) - - await waitFor(() => { - expect(screen.queryByText('Stored XSS in Comments')).not.toBeInTheDocument() - }) - expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) - }) -}) - -// ── Sort options ────────────────────────────────────────────────────────────── - -describe('Findings — sorting', () => { - beforeEach(() => { - vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) - }) - - it('sort dropdown contains all expected options', async () => { - renderFindings() - await waitForLoad() - - const options = within(getSortSelect()).getAllByRole('option') - const labels = options.map((o) => o.textContent) - expect(labels).toContain('Newest First') - expect(labels).toContain('Oldest First') - expect(labels).toContain('Target (A → Z)') - }) - - it('switches to flat list when sort is newest', async () => { - renderFindings() - await waitForLoad() - - fireEvent.change(getSortSelect(), { target: { value: 'newest' } }) - - await waitFor(() => { - const headers = screen.getAllByText(/visible in queue/i) - expect(headers.length).toBe(1) - }) + vi.clearAllMocks() + localStorage.clear() }) - it('newest-first puts most recent finding on top', async () => { - renderFindings() - await waitForLoad() - - fireEvent.change(getSortSelect(), { target: { value: 'newest' } }) - - await waitFor(() => { - const titles = getVisibleTitles() - // May 15 > May 14 > May 13 - expect(titles.indexOf('Missing Security Headers')).toBeLessThan(titles.indexOf('SQL Injection in Login')) - expect(titles.indexOf('SQL Injection in Login')).toBeLessThan(titles.indexOf('Stored XSS in Comments')) - }) - }) - - it('oldest-first puts earliest finding on top', async () => { - renderFindings() - await waitForLoad() - - fireEvent.change(getSortSelect(), { target: { value: 'oldest' } }) - - await waitFor(() => { - const titles = getVisibleTitles() - // May 13 < May 14 < May 15 - expect(titles.indexOf('Stored XSS in Comments')).toBeLessThan(titles.indexOf('SQL Injection in Login')) - expect(titles.indexOf('SQL Injection in Login')).toBeLessThan(titles.indexOf('Missing Security Headers')) - }) - }) - - it('target A-Z sorts alphabetically by target', async () => { - renderFindings() - await waitForLoad() - - fireEvent.change(getSortSelect(), { target: { value: 'target' } }) - - await waitFor(() => { - const titles = getVisibleTitles() - // api.example.com comes before web.example.com - // criticalFinding and mediumFinding share api.example.com, highFinding has web.example.com - const webIdx = titles.indexOf('Stored XSS in Comments') - const apiIdx = titles.indexOf('SQL Injection in Login') - expect(apiIdx).toBeLessThan(webIdx) - }) - }) -}) - -// ── Target filter ───────────────────────────────────────────────────────────── - -describe('Findings — target filter', () => { - beforeEach(() => { - vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) - }) - - it('renders unique targets in dropdown', async () => { - renderFindings() - await waitForLoad() - - const targetSelect = screen.getByDisplayValue(/All Targets/i) - const options = within(targetSelect as HTMLElement).getAllByRole('option') - const labels = options.map((o) => o.textContent) - - expect(labels).toContain('All Targets') - expect(labels).toContain('api.example.com') - expect(labels).toContain('web.example.com') - }) - - it('filters findings when a specific target is selected', async () => { - const user = userEvent.setup() - renderFindings() - await waitForLoad() - - const targetSelect = screen.getByDisplayValue(/All Targets/i) - await user.selectOptions(targetSelect, 'web.example.com') - - await waitFor(() => { - expect(screen.queryByText('SQL Injection in Login')).not.toBeInTheDocument() - }) - expect(screen.getAllByText('Stored XSS in Comments').length).toBeGreaterThanOrEqual(1) - }) -}) - -// ── Scanner / tool filter ───────────────────────────────────────────────────── - -describe('Findings — scanner filter', () => { - beforeEach(() => { - vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) - }) - - it('renders unique scanners in dropdown', async () => { - renderFindings() - await waitForLoad() - - const scannerSelect = screen.getByDisplayValue(/All Scanners/i) - const options = within(scannerSelect as HTMLElement).getAllByRole('option') - const labels = options.map((o) => o.textContent) - - expect(labels).toContain('All Scanners') - expect(labels).toContain('sqlmap') - expect(labels).toContain('zap') - expect(labels).toContain('nikto') - }) - - it('filters findings to one scanner', async () => { - const user = userEvent.setup() - renderFindings() - await waitForLoad() - - const scannerSelect = screen.getByDisplayValue(/All Scanners/i) - await user.selectOptions(scannerSelect, 'zap') - - await waitFor(() => { - expect(screen.queryByText('SQL Injection in Login')).not.toBeInTheDocument() - expect(screen.queryByText('Missing Security Headers')).not.toBeInTheDocument() - }) - expect(screen.getAllByText('Stored XSS in Comments').length).toBeGreaterThanOrEqual(1) - }) -}) - -// ── Date range filter ───────────────────────────────────────────────────────── - -describe('Findings — date range filter', () => { - beforeEach(() => { - vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) + it('renders the page header', async () => { + vi.mocked(getFindings).mockResolvedValue({ findings: [] }) + render(<Findings />) + expect(screen.getByRole('heading', { name: /Findings/i })).toBeInTheDocument() }) - it('filters out findings before the from-date', async () => { - renderFindings() - await waitForLoad() - - // Set from-date to May 14 — should exclude the May 13 finding (highFinding) - const fromLabel = screen.getByText('From Date') - const fromInput = fromLabel.parentElement!.querySelector('input')! - fireEvent.change(fromInput, { target: { value: '2026-05-14' } }) + it('shows loading state then renders findings', async () => { + const findings = [makeFinding({ title: 'SQL Injection' })] + vi.mocked(getFindings).mockResolvedValue({ findings }) - await waitFor(() => { - expect(screen.queryByText('Stored XSS in Comments')).not.toBeInTheDocument() - }) - expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Missing Security Headers').length).toBeGreaterThanOrEqual(1) + render(<Findings />) + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) + expect(screen.getAllByText('SQL Injection').length).toBeGreaterThan(0) }) - it('filters out findings after the to-date', async () => { - renderFindings() - await waitForLoad() - - // Set to-date to May 14 — should exclude the May 15 finding (mediumFinding) - const toLabel = screen.getByText('To Date') - const toInput = toLabel.parentElement!.querySelector('input')! - fireEvent.change(toInput, { target: { value: '2026-05-14' } }) - - await waitFor(() => { - expect(screen.queryByText('Missing Security Headers')).not.toBeInTheDocument() - }) - expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Stored XSS in Comments').length).toBeGreaterThanOrEqual(1) + it('shows empty state when no findings match', async () => { + vi.mocked(getFindings).mockResolvedValue({ findings: [] }) + render(<Findings />) + await waitFor(() => expect(screen.getByText(/No Findings Match/i)).toBeInTheDocument()) }) - it('includes findings on the boundary date', async () => { - renderFindings() - await waitForLoad() + it('does not mount all rows to DOM with 500 findings (DOM bloat test)', async () => { + const findings = makeLargeDataset(500) + vi.mocked(getFindings).mockResolvedValue({ findings }) - // Set from=May 14, to=May 14 — should include criticalFinding (discovered May 14) - const fromLabel = screen.getByText('From Date') - const fromInput = fromLabel.parentElement!.querySelector('input')! - const toLabel = screen.getByText('To Date') - const toInput = toLabel.parentElement!.querySelector('input')! + const { container } = render(<Findings />) + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - fireEvent.change(fromInput, { target: { value: '2026-05-14' } }) - fireEvent.change(toInput, { target: { value: '2026-05-14' } }) - - await waitFor(() => { - expect(screen.queryByText('Stored XSS in Comments')).not.toBeInTheDocument() - expect(screen.queryByText('Missing Security Headers')).not.toBeInTheDocument() - }) - expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) - }) -}) - -// ── Reset button ────────────────────────────────────────────────────────────── - -describe('Findings — reset filters', () => { - beforeEach(() => { - vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) + // Only a small window of rows should be in the DOM — far fewer than 500 + const rows = container.querySelectorAll('[role="option"]') + expect(rows.length).toBeLessThan(60) }) - it('clears all active filters when reset is clicked', async () => { - const user = userEvent.setup() - renderFindings() - await waitForLoad() + it('filters findings by severity pill', async () => { + const findings = [ + makeFinding({ id: 'c1', severity: 'critical', title: 'Critical Finding' }), + makeFinding({ id: 'h1', severity: 'high', title: 'High Finding' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) - // Apply a target filter first - const targetSelect = screen.getByDisplayValue(/All Targets/i) - await user.selectOptions(targetSelect, 'web.example.com') - - await waitFor(() => { - expect(screen.queryByText('SQL Injection in Login')).not.toBeInTheDocument() - }) + render(<Findings />) + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - // Now click reset - await user.click(screen.getByRole('button', { name: /reset filters/i })) + // Click the Critical pill — use first match (the filter pill, not any row label) + const criticalPill = screen.getAllByRole('button', { name: /Critical/i })[0] + await userEvent.click(criticalPill) - await waitFor(() => { - expect(screen.getAllByText('SQL Injection in Login').length).toBeGreaterThanOrEqual(1) - expect(screen.getAllByText('Stored XSS in Comments').length).toBeGreaterThanOrEqual(1) - }) + // Query within the virtual list only to avoid detail panel duplicates + const list = screen.getByRole('listbox') + expect(list.querySelector('[role="option"]')).toBeInTheDocument() + expect(screen.getAllByText('Critical Finding').length).toBeGreaterThan(0) + expect(screen.queryByText('High Finding')).not.toBeInTheDocument() }) -}) -// ── Timezone boundary regression ────────────────────────────────────────────── -// A finding at 2026-05-13T20:00:00Z is May 14 01:30 in Asia/Kolkata (IST). -// The date filter should compare by the *displayed* calendar day, not UTC. + it('filters findings by search query', async () => { + const findings = [ + makeFinding({ title: 'XSS Attack Vector', description: 'Cross site scripting' }), + makeFinding({ title: 'SQL Injection', description: 'Database attack' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) -describe('Findings — date range respects display timezone', () => { - const tzBoundaryFinding = { - id: 'finding-tz-edge', - severity: 'high', - category: 'xss', - title: 'TZ Boundary XSS', - target: 'tz.example.com', - description: 'Edge case across UTC day boundary.', - remediation: 'Fix it.', - discovered_at: '2026-05-13T20:00:00Z', // May 13 UTC, but May 14 in IST - plugin_id: 'zap', - } + render(<Findings />) + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - beforeEach(() => { - vi.mocked(getFindings).mockResolvedValue({ findings: [tzBoundaryFinding] }) - // Force timezone to Asia/Kolkata so the finding displays as May 14 - vi.spyOn(dateUtils, 'getCurrentTimeZone').mockReturnValue('Asia/Kolkata') - }) + const searchInput = screen.getByPlaceholderText(/Title, target, CVE/i) + await userEvent.type(searchInput, 'XSS') - afterEach(() => { - vi.restoreAllMocks() + // Use getAllByText since title appears in both list row and detail panel + expect(screen.getAllByText('XSS Attack Vector').length).toBeGreaterThan(0) + expect(screen.queryByText('SQL Injection')).not.toBeInTheDocument() }) - it('includes a UTC May-13 finding when from-date is May-14 in IST', async () => { - renderFindings() + it('selects a finding when clicked and shows it in the detail panel', async () => { + const findings = [ + makeFinding({ id: 'f1', title: 'Finding Alpha', severity: 'critical' }), + makeFinding({ id: 'f2', title: 'Finding Beta', severity: 'critical' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) - await waitFor(() => { - expect(screen.getAllByText('TZ Boundary XSS').length).toBeGreaterThanOrEqual(1) - }) + render(<Findings />) + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - const fromLabel = screen.getByText('From Date') - const fromInput = fromLabel.parentElement!.querySelector('input')! - fireEvent.change(fromInput, { target: { value: '2026-05-14' } }) + // Click within the list to select a different finding + const listbox = screen.getByRole('listbox') + const betaOption = listbox.querySelector('[role="option"][aria-label*="Finding Beta"], [role="option"]') + if (betaOption) await userEvent.click(betaOption) - // In IST this finding is May 14, so from-date of May 14 should keep it - await waitFor(() => { - expect(screen.getAllByText('TZ Boundary XSS').length).toBeGreaterThanOrEqual(1) - }) + expect(screen.getByText('Selected Finding')).toBeInTheDocument() }) - it('excludes the finding when from-date is May-15 in IST', async () => { - renderFindings() + it('keyboard ArrowDown moves selection forward', async () => { + const findings = [ + makeFinding({ id: 'f1', title: 'First Finding', severity: 'critical' }), + makeFinding({ id: 'f2', title: 'Second Finding', severity: 'critical' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) - await waitFor(() => { - expect(screen.getAllByText('TZ Boundary XSS').length).toBeGreaterThanOrEqual(1) - }) + render(<Findings />) + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - const fromLabel = screen.getByText('From Date') - const fromInput = fromLabel.parentElement!.querySelector('input')! - fireEvent.change(fromInput, { target: { value: '2026-05-15' } }) + const listbox = screen.getByRole('listbox') + listbox.focus() + fireEvent.keyDown(listbox, { key: 'ArrowDown' }) - // May 14 IST < May 15 from-date, so it should be excluded await waitFor(() => { - expect(screen.getByText(/No Findings Match/i)).toBeInTheDocument() + const selected = screen.getByRole('option', { name: /Second Finding/i }) + expect(selected).toHaveAttribute('aria-selected', 'true') }) }) -}) - -// ── Empty state ─────────────────────────────────────────────────────────────── - -describe('Findings — empty state', () => { - it('shows empty state when no findings exist', async () => { - vi.mocked(getFindings).mockResolvedValue({ findings: [] }) - renderFindings() - expect(await screen.findByText(/No Findings Match/i)).toBeInTheDocument() - }) -}) - -// ── Active filter summary ───────────────────────────────────────────────────── - -describe('Findings — active filter summary', () => { - beforeEach(() => { - vi.mocked(getFindings).mockResolvedValue({ findings: allFindings }) - }) - - it('is hidden when no filters are active', async () => { - renderFindings() - await waitForLoad() - expect(screen.queryByLabelText('active filters')).not.toBeInTheDocument() - }) - - it('shows target + scanner chips when both filters are applied', async () => { - const user = userEvent.setup() - renderFindings() - await waitForLoad() - - await user.selectOptions(screen.getByDisplayValue(/All Targets/i), 'api.example.com') - await user.selectOptions(screen.getByDisplayValue(/All Scanners/i), 'sqlmap') - - const strip = await screen.findByLabelText('active filters') - expect(strip).toBeInTheDocument() - expect(within(strip).getByText(/target: api\.example\.com/i)).toBeInTheDocument() - expect(within(strip).getByText(/scanner: sqlmap/i)).toBeInTheDocument() - }) - it('shows date range chips when both dates are set', async () => { - renderFindings() - await waitForLoad() + it('keyboard ArrowDown follows sorted order in non-severity sort mode', async () => { + const findings = [ + makeFinding({ id: 'f1', title: 'Alpha Finding', severity: 'high', discovered_at: '2024-01-01T00:00:00Z' }), + makeFinding({ id: 'f2', title: 'Beta Finding', severity: 'critical', discovered_at: '2024-01-03T00:00:00Z' }), + makeFinding({ id: 'f3', title: 'Gamma Finding', severity: 'medium', discovered_at: '2024-01-02T00:00:00Z' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) - const fromInput = screen.getByText('From Date').parentElement!.querySelector('input')! - const toInput = screen.getByText('To Date').parentElement!.querySelector('input')! + render(<Findings />) + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - fireEvent.change(fromInput, { target: { value: '2026-05-14' } }) - fireEvent.change(toInput, { target: { value: '2026-05-15' } }) + // Switch to "newest" sort — order should be f2 (Jan 3), f3 (Jan 2), f1 (Jan 1) + const selects = screen.getAllByRole('combobox') + const sortSelect = selects.find((s) => + Array.from(s.querySelectorAll('option')).some((o) => /Newest First/i.test(o.textContent || '')), + ) + expect(sortSelect).toBeDefined() + await userEvent.selectOptions(sortSelect!, 'newest') - const strip = await screen.findByLabelText('active filters') - expect(within(strip).getByText(/from: 2026-05-14/i)).toBeInTheDocument() - expect(within(strip).getByText(/to: 2026-05-15/i)).toBeInTheDocument() - }) -}) - -// ── Risk score display ──────────────────────────────────────────────────────── - -describe('Findings — risk score display', () => { - const riskFactors = [ - { factor: 'severity', label: 'Severity', value: 'critical', score: 10.0, weight: 0.30, contribution: 3.0, detail: 'Severity is critical (10.0/10)' }, - { factor: 'exploitability', label: 'Exploitability', value: 8.0, score: 8.0, weight: 0.25, contribution: 2.0, detail: 'Exploitability score is 8.0/10' }, - { factor: 'asset_exposure', label: 'Asset Exposure', value: 'critical', score: 10.0, weight: 0.20, contribution: 2.0, detail: 'Asset exposure is critical (10.0/10)' }, - { factor: 'recency', label: 'Recency', value: '2026-05-14T10:00:00Z', score: 10.0, weight: 0.15, contribution: 1.5, detail: 'Discovered today — maximum recency score' }, - { factor: 'confidence', label: 'Confidence', value: 0.95, score: 9.5, weight: 0.10, contribution: 0.95, detail: 'Confidence is 95%' }, - ] - - const criticalFindingWithRisk = { - ...criticalFinding, - risk_score: 8.7, - risk_factors: riskFactors, - } - - beforeEach(() => { - vi.mocked(getFindings).mockResolvedValue({ findings: [criticalFindingWithRisk, highFinding, mediumFinding] }) - }) + // Select the first item in the sorted list (Beta Finding), then ArrowDown should go to Gamma + const betaOption = await screen.findByRole('option', { name: /Beta Finding/i }) + await userEvent.click(betaOption) - it('shows risk score in sidebar when available', async () => { - renderFindings() - await waitForLoad() + const listbox = screen.getByRole('listbox') + listbox.focus() + fireEvent.keyDown(listbox, { key: 'ArrowDown' }) await waitFor(() => { - expect(screen.getByText('Risk Score')).toBeInTheDocument() + const selected = screen.getByRole('option', { name: /Gamma Finding/i }) + expect(selected).toHaveAttribute('aria-selected', 'true') }) - expect(screen.getByText('8.7')).toBeInTheDocument() }) - it('shows risk factor breakdown with labels and contributions', async () => { - renderFindings() - await waitForLoad() + it('workflow actions (mark reviewed, suppress, reopen) update status chip', async () => { + const findings = [makeFinding({ id: 'f1', title: 'Actionable Finding', severity: 'high' })] + vi.mocked(getFindings).mockResolvedValue({ findings }) - await waitFor(() => { - expect(screen.getByText('Severity')).toBeInTheDocument() - }) - expect(screen.getByText('Exploitability')).toBeInTheDocument() - expect(screen.getByText('Asset Exposure')).toBeInTheDocument() - expect(screen.getByText('Recency')).toBeInTheDocument() - expect(screen.getByText('Confidence')).toBeInTheDocument() - }) + render(<Findings />) + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - it('shows weight percentages for each risk factor', async () => { - renderFindings() - await waitForLoad() + await userEvent.click(screen.getByRole('button', { name: /Mark Reviewed/i })) await waitFor(() => { - expect(screen.getByText('(30%)')).toBeInTheDocument() + const reviewedChips = screen.getAllByText('reviewed') + expect(reviewedChips.length).toBeGreaterThan(0) }) - expect(screen.getByText('(25%)')).toBeInTheDocument() - expect(screen.getByText('(20%)')).toBeInTheDocument() - expect(screen.getByText('(15%)')).toBeInTheDocument() - expect(screen.getByText('(10%)')).toBeInTheDocument() }) - it('shows risk score in red for high values (>= 7)', async () => { - renderFindings() - await waitForLoad() + it('persists review state to localStorage', async () => { + const findings = [makeFinding({ id: 'f1', title: 'Persist Test', severity: 'high' })] + vi.mocked(getFindings).mockResolvedValue({ findings }) - await waitFor(() => { - const scoreEl = screen.getByText('8.7') - expect(scoreEl.className).toContain('text-rag-red') - }) - }) + render(<Findings />) + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - it('shows risk score in amber for medium values (4-6.9)', async () => { - const mediumWithRisk = { ...mediumFinding, risk_score: 5.2, risk_factors: riskFactors.map(f => ({ ...f, score: 5 })) } - vi.mocked(getFindings).mockResolvedValue({ findings: [mediumWithRisk] }) - renderFindings() + await userEvent.click(screen.getByRole('button', { name: /Mark Reviewed/i })) - await waitFor(() => { - expect(screen.getByText('5.2')).toBeInTheDocument() - }) - const scoreEl = screen.getByText('5.2') - expect(scoreEl.className).toContain('text-rag-amber') + const stored = JSON.parse(localStorage.getItem('secuscan-finding-review-state') ?? '{}') + expect(stored['f1']).toBe('reviewed') }) - it('shows risk score in blue for low values (< 4)', async () => { - const lowWithRisk = { ...mediumFinding, severity: 'low', risk_score: 2.1, risk_factors: riskFactors.map(f => ({ ...f, score: 2 })) } - vi.mocked(getFindings).mockResolvedValue({ findings: [lowWithRisk] }) - renderFindings() + it('negative: suppressed finding shows suppressed status chip', async () => { + const findings = [ + makeFinding({ id: 's1', title: 'Suppressed Finding', severity: 'low' }), + makeFinding({ id: 'a1', title: 'Active Finding', severity: 'high' }), + ] + vi.mocked(getFindings).mockResolvedValue({ findings }) - await waitFor(() => { - expect(screen.getByText('2.1')).toBeInTheDocument() - }) - const scoreEl = screen.getByText('2.1') - expect(scoreEl.className).toContain('text-rag-blue') - }) + localStorage.setItem('secuscan-finding-review-state', JSON.stringify({ s1: 'suppressed' })) - it('does not show risk score section when finding has no risk_score', async () => { - vi.mocked(getFindings).mockResolvedValue({ findings: [highFinding, mediumFinding] }) - renderFindings() + render(<Findings />) + await waitFor(() => expect(screen.queryByText('Synchronizing findings feed...')).not.toBeInTheDocument()) - await waitFor(() => { - expect(screen.getByText(/Stored XSS in Comments/i)).toBeInTheDocument() - }) - expect(screen.queryByText('Risk Score')).not.toBeInTheDocument() + const suppressedChips = screen.queryAllByText('suppressed') + expect(suppressedChips.length).toBeGreaterThan(0) }) }) diff --git a/frontend/testing/unit/pages/ReportCompare.test.tsx b/frontend/testing/unit/pages/ReportCompare.test.tsx new file mode 100644 index 000000000..d5de326a8 --- /dev/null +++ b/frontend/testing/unit/pages/ReportCompare.test.tsx @@ -0,0 +1,111 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router-dom' +import ReportCompare from '../../../src/pages/ReportCompare' +import { getFindings, getReports } from '../../../src/api' + +vi.mock('../../../src/api', () => ({ + getReports: vi.fn(), + getFindings: vi.fn(), +})) + +const readyA = { + id: 'report-a', + task_id: 'task-a', + name: 'Scan A', + type: 'technical', + generated_at: '2026-05-01T10:00:00Z', + status: 'ready', + findings: 1, + assets: 1, + pages: 1, +} + +const readyB = { + id: 'report-b', + task_id: 'task-b', + name: 'Scan B', + type: 'technical', + generated_at: '2026-05-02T10:00:00Z', + status: 'ready', + findings: 2, + assets: 1, + pages: 1, +} + +function renderCompare() { + return render( + <MemoryRouter> + <ReportCompare /> + </MemoryRouter>, + ) +} + +describe('ReportCompare page', () => { + beforeEach(() => { + vi.mocked(getReports).mockResolvedValue({ reports: [readyA, readyB] }) + vi.mocked(getFindings).mockResolvedValue({ + findings: [ + { + id: 'f1', + task_id: 'task-a', + title: 'Only in A', + target: '127.0.0.1', + category: 'network', + severity: 'high', + }, + { + id: 'f2', + task_id: 'task-b', + title: 'Only in A', + target: '127.0.0.1', + category: 'network', + severity: 'high', + }, + { + id: 'f3', + task_id: 'task-b', + title: 'Only in B', + target: '127.0.0.1', + category: 'network', + severity: 'critical', + }, + ], + }) + }) + + it('lists failed reports when they still have findings', async () => { + vi.mocked(getReports).mockResolvedValue({ + reports: [ + { ...readyA, status: 'failed' }, + { ...readyB, status: 'failed' }, + ], + }) + renderCompare() + + await waitFor(() => { + const options = screen.getAllByRole('option') + expect(options.some((o) => o.textContent?.includes('Scan A'))).toBe(true) + expect(options.some((o) => o.textContent?.includes('Scan B'))).toBe(true) + }) + }) + + it('renders compare selectors and diff sections', async () => { + const user = userEvent.setup() + renderCompare() + + expect(await screen.findByRole('heading', { name: /compare reports/i })).toBeInTheDocument() + + const selects = screen.getAllByRole('combobox') + await user.selectOptions(selects[0], 'report-a') + await user.selectOptions(selects[1], 'report-b') + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /new findings/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /fixed findings/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /^unchanged$/i })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /severity changed/i })).toBeInTheDocument() + expect(screen.getByText(/Only in B/i)).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx b/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx index e5687411b..3dae06386 100644 --- a/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx +++ b/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx @@ -97,4 +97,29 @@ describe('Reports — preferred export format', () => { const htmlBtn = await screen.findByRole('button', { name: /^html$/i }) expect(htmlBtn.className).toContain('bg-rag-amber') }) -}) + + it('header download button uses the preferred format instead of defaulting to pdf', async () => { + localStorage.setItem('secuscan:preferred-export-format', 'sarif') + renderReports() + + const headerBtn = await screen.findByRole('button', { name: /download latest ready report sarif/i }) + await userEvent.click(headerBtn) + + expect(window.open).toHaveBeenCalledWith( + expect.stringContaining('/report/sarif'), + '_blank', + ) + }) + + it('header download button defaults to pdf when no preference is saved', async () => { + renderReports() + + const headerBtn = await screen.findByRole('button', { name: /download latest ready report pdf/i }) + await userEvent.click(headerBtn) + + expect(window.open).toHaveBeenCalledWith( + expect.stringContaining('/report/pdf'), + '_blank', + ) + }) +}) \ No newline at end of file diff --git a/frontend/testing/unit/pages/Scans.test.tsx b/frontend/testing/unit/pages/Scans.test.tsx new file mode 100644 index 000000000..994f36581 --- /dev/null +++ b/frontend/testing/unit/pages/Scans.test.tsx @@ -0,0 +1,216 @@ +import React from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { MemoryRouter } from 'react-router-dom' +import Scans from '../../../src/pages/Scans' + +// ── Mocks ──────────────────────────────────────────────────────────────────── + +vi.mock('../../../src/api', () => ({ + API_BASE: 'http://localhost:5000', + deleteTask: vi.fn().mockResolvedValue({}), + clearAllTasks: vi.fn().mockResolvedValue({}), + bulkDeleteTasks: vi.fn().mockResolvedValue({}), +})) + +vi.mock('../../../src/routes', () => ({ + routePath: { task: (id: string) => `/task/${id}` }, +})) + +vi.mock('../../../src/utils/date', () => ({ + parseDateSafe: (d: any) => new Date(d || Date.now()), + formatLocaleDate: () => '2024-01-01', + formatLocaleTime: () => '12:00', +})) + +vi.mock('../../../src/components/ConfirmModal', () => ({ + ConfirmModal: ({ isOpen, onConfirm, title }: any) => + isOpen ? ( + <div> + <span>{title}</span> + <button onClick={onConfirm}>Confirm</button> + </div> + ) : null, +})) + +vi.mock('../../../src/components/Pagination', () => ({ + default: ({ page, onNext, onPrev }: any) => ( + <div> + <button onClick={onPrev}>Prev</button> + <span>Page {page}</span> + <button onClick={onNext}>Next</button> + </div> + ), +})) + +global.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), +})) + +Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { configurable: true, value: 800 }) +Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, value: 600 }) + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function makeTask(overrides: any = {}) { + const id = overrides.task_id ?? `task-${Math.random().toString(36).slice(2)}` + return { + task_id: id, + plugin_id: 'nmap', + tool: 'nmap', + target: 'example.com', + status: 'completed' as const, + created_at: '2024-01-01T00:00:00Z', + duration_seconds: 30, + ...overrides, + } +} + +function mockFetch(tasks: ReturnType<typeof makeTask>[], total?: number) { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ + tasks, + pagination: { total_items: total ?? tasks.length }, + }), + } as any) +} + +function renderScans() { + return render( + <MemoryRouter> + <Scans /> + </MemoryRouter> + ) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('Scans — task list', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers({ shouldAdvanceTime: true }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders the page header', () => { + mockFetch([]) + renderScans() + expect(screen.getByRole('heading', { name: /Operational/i })).toBeInTheDocument() + }) + + it('shows empty state when there are no tasks', async () => { + mockFetch([]) + renderScans() + await waitFor(() => expect(screen.getByText(/Archive Isolated/i)).toBeInTheDocument()) + }) + + it('renders task cards for loaded tasks', async () => { + const tasks = [makeTask({ tool: 'nmap', target: 'target.com' })] + mockFetch(tasks) + renderScans() + await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument()) + expect(screen.getByText('target.com')).toBeInTheDocument() + }) + + it('status filter buttons are rendered and clickable', async () => { + mockFetch([]) + renderScans() + const allBtn = screen.getByRole('button', { name: /ALL_OPERATIONS/i }) + expect(allBtn).toBeInTheDocument() + await userEvent.click(allBtn) + }) + + it('select-all selects all tasks', async () => { + const tasks = [makeTask({ task_id: 'task-1' }), makeTask({ task_id: 'task-2' })] + mockFetch(tasks) + renderScans() + + await waitFor(() => expect(screen.getAllByText(/nmap/i).length).toBeGreaterThan(0)) + + await userEvent.click(screen.getByRole('button', { name: /Select_All/i })) + + await waitFor(() => { + expect(screen.getByText('2')).toBeInTheDocument() + }) + }) + + it('cancel clears selection', async () => { + const tasks = [makeTask({ task_id: 'task-1' })] + mockFetch(tasks) + renderScans() + + await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /Select_All/i })) + await waitFor(() => expect(screen.getByText(/Records_Selected_For_Pruning/i)).toBeInTheDocument()) + + await userEvent.click(screen.getByRole('button', { name: /Cancel/i })) + + await waitFor(() => { + const selectAllBtn = screen.getByRole('button', { name: /Select_All/i }) + expect(selectAllBtn.className).not.toContain('bg-rag-blue') + }) + }) + + it('polls every 5 seconds', async () => { + mockFetch([]) + renderScans() + + expect(global.fetch).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(5000) + await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2)) + }) + + it('shows pagination when total exceeds page limit', async () => { + const tasks = Array.from({ length: 10 }, (_, i) => makeTask({ task_id: `task-${i}` })) + mockFetch(tasks, 25) + renderScans() + + await waitFor(() => expect(screen.getAllByText(/nmap/i).length).toBeGreaterThan(0)) + expect(screen.getByText(/Page 1/i)).toBeInTheDocument() + }) + + it('negative: Delete_Record button NOT shown for running tasks', async () => { + const tasks = [makeTask({ status: 'running' })] + mockFetch(tasks) + renderScans() + + await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument()) + + await userEvent.click(screen.getByText('nmap').closest('[class*="cursor-pointer"]')!) + + expect(screen.queryByText('Delete_Record')).not.toBeInTheDocument() + }) + + it('negative: bulk delete not triggered with empty selection', async () => { + mockFetch([makeTask()]) + renderScans() + + await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument()) + + const { bulkDeleteTasks } = await import('../../../src/api') + expect(bulkDeleteTasks).not.toHaveBeenCalled() + }) + + it('shows confirm modal when deleting a task', async () => { + const tasks = [makeTask({ task_id: 'task-1', status: 'completed' })] + mockFetch(tasks) + renderScans() + + await waitFor(() => expect(screen.getByText('nmap')).toBeInTheDocument()) + + await userEvent.click(screen.getByText('nmap').closest('[class*="cursor-pointer"]')!) + await waitFor(() => expect(screen.getByText('Delete_Record')).toBeInTheDocument()) + + await userEvent.click(screen.getByText('Delete_Record')) + + await waitFor(() => expect(screen.getByText('Delete Scan Record')).toBeInTheDocument()) + }) +}) diff --git a/frontend/testing/unit/pages/SettingsExport.test.tsx b/frontend/testing/unit/pages/SettingsExport.test.tsx new file mode 100644 index 000000000..2df6de2ca --- /dev/null +++ b/frontend/testing/unit/pages/SettingsExport.test.tsx @@ -0,0 +1,139 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { vi, describe, it, expect, beforeEach } from 'vitest' +import Settings from '../../../src/pages/Settings' +import { ThemeProvider } from '../../../src/components/ThemeContext' +import { ToastProvider } from '../../../src/components/ToastContext' +import { listNotificationRules } from '../../../src/api' + +vi.mock('../../../src/api', async () => { + const actual: any = await vi.importActual('../../../src/api') + return { + ...actual, + listNotificationRules: vi.fn(), + } +}) + +function renderSettings() { + render( + <ThemeProvider> + <ToastProvider> + <Settings /> + </ToastProvider> + </ThemeProvider>, + ) +} + +describe('Settings export flow', () => { + beforeEach(() => { + localStorage.removeItem('secuscan-config') + vi.mocked(listNotificationRules).mockResolvedValue([]) + }) + + it('creates a download anchor with a JSON data URI and clicks it', async () => { + const user = userEvent.setup() + renderSettings() + + const createdAnchors: HTMLAnchorElement[] = [] + const originalCreate = document.createElement.bind(document) + + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreate(tag) + if (tag === 'a') { + vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {}) + createdAnchors.push(el as HTMLAnchorElement) + } + return el + }) + + await user.click(screen.getByRole('button', { name: /TELEMETRY_EXPORT/i })) + + expect(createdAnchors).toHaveLength(1) + const anchor = createdAnchors[0] + + expect(anchor.getAttribute('href')).toMatch(/^data:text\/json/) + expect(anchor.click).toHaveBeenCalledOnce() + + vi.restoreAllMocks() + }) + + it('sets the download filename to secuscan_config_<today>.json', async () => { + const user = userEvent.setup() + renderSettings() + + const createdAnchors: HTMLAnchorElement[] = [] + const originalCreate = document.createElement.bind(document) + + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreate(tag) + if (tag === 'a') { + vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {}) + createdAnchors.push(el as HTMLAnchorElement) + } + return el + }) + + await user.click(screen.getByRole('button', { name: /TELEMETRY_EXPORT/i })) + + const today = new Date().toISOString().split('T')[0] + expect(createdAnchors[0].getAttribute('download')).toBe( + `secuscan_config_${today}.json`, + ) + + vi.restoreAllMocks() + }) + + it('exports a valid JSON string containing the current config', async () => { + const user = userEvent.setup() + renderSettings() + + const createdAnchors: HTMLAnchorElement[] = [] + const originalCreate = document.createElement.bind(document) + + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreate(tag) + if (tag === 'a') { + vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {}) + createdAnchors.push(el as HTMLAnchorElement) + } + return el + }) + + await user.click(screen.getByRole('button', { name: /TELEMETRY_EXPORT/i })) + + const href = createdAnchors[0].getAttribute('href') ?? '' + const jsonStr = decodeURIComponent( + href.replace('data:text/json;charset=utf-8,', ''), + ) + const exported = JSON.parse(jsonStr) + + expect(exported.concurrentScans).toBe(8) + expect(exported.scanIntensity).toBe('standard') + expect(exported.theme).toBe('dark') + + vi.restoreAllMocks() + }) + + it('removes the anchor from the DOM after the download is triggered', async () => { + const user = userEvent.setup() + renderSettings() + + const createdAnchors: HTMLAnchorElement[] = [] + const originalCreate = document.createElement.bind(document) + + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreate(tag) + if (tag === 'a') { + vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {}) + createdAnchors.push(el as HTMLAnchorElement) + } + return el + }) + + await user.click(screen.getByRole('button', { name: /TELEMETRY_EXPORT/i })) + + expect(document.body.contains(createdAnchors[0])).toBe(false) + + vi.restoreAllMocks() + }) +}) diff --git a/frontend/testing/unit/pages/SettingsNotifications.test.tsx b/frontend/testing/unit/pages/SettingsNotifications.test.tsx new file mode 100644 index 000000000..54c9ff71f --- /dev/null +++ b/frontend/testing/unit/pages/SettingsNotifications.test.tsx @@ -0,0 +1,91 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import Settings from '../../../src/pages/Settings' +import { ThemeProvider } from '../../../src/components/ThemeContext' +import { ToastProvider } from '../../../src/components/ToastContext' +import { + createNotificationRule, + deleteNotificationRule, + listNotificationRules, + updateNotificationRule, + listNotificationHistory, +} from '../../../src/api' + +vi.mock('../../../src/api', async () => { + const actual: any = await vi.importActual('../../../src/api') + return { + ...actual, + listNotificationRules: vi.fn(), + createNotificationRule: vi.fn(), + updateNotificationRule: vi.fn(), + deleteNotificationRule: vi.fn(), + listNotificationHistory: vi.fn(), + } +}) + +function renderSettings() { + render( + <ThemeProvider> + <ToastProvider> + <Settings /> + </ToastProvider> + </ThemeProvider>, + ) +} + +describe('Settings notifications rules panel', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.mocked(listNotificationRules).mockResolvedValue([ + { + id: 'rule-1', + name: 'Critical hook', + severity_threshold: 'critical', + channel_type: 'webhook', + target_url_or_email: 'https://example.com/hook', + is_active: true, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + }, + ] as any) + vi.mocked(createNotificationRule).mockResolvedValue({} as any) + vi.mocked(updateNotificationRule).mockResolvedValue({} as any) + vi.mocked(deleteNotificationRule).mockResolvedValue({ rule_id: 'rule-1', deleted: true } as any) + vi.mocked(listNotificationHistory).mockResolvedValue({ history: [], total: 0, limit: 10, offset: 0 } as any) + }) + + it('renders existing rules from API', async () => { + renderSettings() + expect(await screen.findByRole('heading', { name: /Notification_Rules/i })).toBeInTheDocument() + expect(await screen.findByDisplayValue(/Critical hook/i)).toBeInTheDocument() + }) + + it('can create a rule', async () => { + const user = userEvent.setup() + renderSettings() + + await user.type(await screen.findByLabelText(/New rule name/i), 'High email') + await user.type(screen.getByLabelText(/New rule target/i), 'alerts@example.com') + await user.selectOptions(screen.getByLabelText(/New rule channel type/i), 'email') + await user.selectOptions(screen.getByLabelText(/New rule severity threshold/i), 'high') + + await user.click(screen.getByRole('button', { name: /CREATE_RULE/i })) + + await waitFor(() => { + expect(createNotificationRule).toHaveBeenCalled() + }) + await waitFor(() => { + expect(listNotificationRules).toHaveBeenCalled() + }) + }) + + it('can toggle active state', async () => { + const user = userEvent.setup() + renderSettings() + const toggle = await screen.findByRole('button', { name: /Toggle rule rule-1/i }) + await user.click(toggle) + await waitFor(() => { + expect(updateNotificationRule).toHaveBeenCalledWith('rule-1', { is_active: false }) + }) + }) +}) diff --git a/frontend/testing/unit/pages/SettingsSaveReset.test.tsx b/frontend/testing/unit/pages/SettingsSaveReset.test.tsx index b50220a73..6c9a1106c 100644 --- a/frontend/testing/unit/pages/SettingsSaveReset.test.tsx +++ b/frontend/testing/unit/pages/SettingsSaveReset.test.tsx @@ -71,4 +71,39 @@ describe('Settings save/reset behavior', () => { const concurrentOps = getInputByLabelText(/Concurrent_Operations/i) expect(concurrentOps.value).toBe('8') }) + it('nuclear purge removes only secuscan-owned keys and preserves unrelated keys', async () => { + // Set up SecuScan keys + // Set up SecuScan keys + window.localStorage.setItem('secuscan-config', JSON.stringify(DEFAULT_CONFIG)) + window.localStorage.setItem('secuscan_api_key', 'test-api-key') + window.localStorage.setItem('secuscan-saved-views', JSON.stringify([])) + window.localStorage.setItem('secuscan-finding-review-state', JSON.stringify({})) + window.localStorage.setItem('secuscan:preferred-export-format', 'html') + window.localStorage.setItem('secuscan_recent_tools', JSON.stringify([])) + window.localStorage.setItem('secuscan-theme', 'dark') + window.localStorage.setItem('sidebar-expanded', 'true') + // Set up an unrelated key + window.localStorage.setItem('some-other-app-key', 'should-not-be-deleted') + + const user = userEvent.setup() + renderSettings() + + await user.click(screen.getByRole('button', { name: /NUCLEAR_PURGE/i })) + + const confirmButton = await screen.findByRole('button', { name: /confirm/i }) + await user.click(confirmButton) + + // SecuScan keys should be gone + expect(window.localStorage.getItem('secuscan-config')).toBeNull() + expect(window.localStorage.getItem('secuscan_api_key')).toBeNull() + expect(window.localStorage.getItem('secuscan-saved-views')).toBeNull() + expect(window.localStorage.getItem('secuscan-finding-review-state')).toBeNull() + expect(window.localStorage.getItem('secuscan:preferred-export-format')).toBeNull() + expect(window.localStorage.getItem('secuscan_recent_tools')).toBeNull() + expect(window.localStorage.getItem('secuscan-theme')).toBeNull() + expect(window.localStorage.getItem('sidebar-expanded')).toBeNull() + + // Unrelated key should still be there + expect(window.localStorage.getItem('some-other-app-key')).toBe('should-not-be-deleted') +}) }) diff --git a/frontend/testing/unit/pages/SettingsTheme.test.tsx b/frontend/testing/unit/pages/SettingsTheme.test.tsx index ffccec8ac..36ee93c74 100644 --- a/frontend/testing/unit/pages/SettingsTheme.test.tsx +++ b/frontend/testing/unit/pages/SettingsTheme.test.tsx @@ -3,35 +3,58 @@ import userEvent from '@testing-library/user-event' import Settings from '../../../src/pages/Settings' import { ThemeProvider } from '../../../src/components/ThemeContext' import { ToastProvider } from '../../../src/components/ToastContext' +import { listNotificationRules } from '../../../src/api' + +vi.mock('../../../src/api', async () => { + const actual: any = await vi.importActual('../../../src/api') + return { + ...actual, + listNotificationRules: vi.fn(), + } +}) + +function renderSettings() { + render( + <ThemeProvider> + <ToastProvider> + <Settings /> + </ToastProvider> + </ThemeProvider>, + ) +} describe('Settings theme wiring', () => { beforeEach(() => { window.localStorage.removeItem('secuscan-theme') document.documentElement.classList.remove('theme-light') + vi.mocked(listNotificationRules).mockResolvedValue([]) }) it('applies selected theme globally and persists it', async () => { const user = userEvent.setup() + renderSettings() - render( - <ThemeProvider> - <ToastProvider> - <Settings /> - </ToastProvider> - </ThemeProvider>, - ) + const themeSelect = screen.getByRole('combobox', { name: /visual spectrum theme/i }) - const themeSelect = screen.getAllByRole('combobox')[3] await user.selectOptions(themeSelect, 'light') await user.click(screen.getByRole('button', { name: /COMMIT_ENGINE_CHANGES/i })) - expect(document.documentElement.classList.contains('theme-light')).toBe(true) expect(window.localStorage.getItem('secuscan-theme')).toBe('light') - await user.selectOptions(screen.getAllByRole('combobox')[3], 'dark') + await user.selectOptions(themeSelect, 'dark') await user.click(screen.getByRole('button', { name: /COMMIT_ENGINE_CHANGES/i })) - expect(document.documentElement.classList.contains('theme-light')).toBe(false) expect(window.localStorage.getItem('secuscan-theme')).toBe('dark') }) + + it('opens reset confirmation modal when ENGINE_RESET is clicked', async () => { + const user = userEvent.setup() + renderSettings() + + await user.click(screen.getByRole('button', { name: /ENGINE_RESET/i })) + + expect( + screen.getByText(/Restore engine to factory specifications/i), + ).toBeInTheDocument() + }) }) diff --git a/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx b/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx index 6cd5a1da7..9aa5c6ebc 100644 --- a/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx +++ b/frontend/testing/unit/pages/ToolConfigDynamic.test.tsx @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' import ToolConfig from '../../../src/pages/ToolConfig' -import { getPluginSchema, listPlugins, startTask } from '../../../src/api' +import { getPluginSchema, listPlugins, startTask, getSettings, listTargetPolicies, listCredentialProfiles, listSessionProfiles } from '../../../src/api' import { routes } from '../../../src/routes' const addToast = vi.fn() @@ -15,6 +15,10 @@ vi.mock('../../../src/api', () => ({ listPlugins: vi.fn(), getPluginSchema: vi.fn(), startTask: vi.fn(), + getSettings: vi.fn(), + listTargetPolicies: vi.fn(), + listCredentialProfiles: vi.fn(), + listSessionProfiles: vi.fn(), })) describe('ToolConfig dynamic schema flow', () => { @@ -74,6 +78,10 @@ describe('ToolConfig dynamic schema flow', () => { created_at: 'now', stream_url: '/api/v1/task/task-123/stream', }) + vi.mocked(getSettings).mockResolvedValue(null) + vi.mocked(listTargetPolicies).mockResolvedValue({ items: [], total: 0 }) + vi.mocked(listCredentialProfiles).mockResolvedValue({ items: [], total: 0 }) + vi.mocked(listSessionProfiles).mockResolvedValue({ items: [], total: 0 }) }) it('renders dynamic fields and submits startTask with consent', async () => { @@ -108,6 +116,7 @@ describe('ToolConfig dynamic schema flow', () => { }), true, 'quick', + expect.any(Object), ) }) }) diff --git a/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx b/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx index 4c0b2213d..44ef9b4d5 100644 --- a/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx +++ b/frontend/testing/unit/pages/ToolConfigTimeout.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { MemoryRouter, Route, Routes } from 'react-router-dom' import ToolConfig from '../../../src/pages/ToolConfig' -import { getPluginSchema, listPlugins, startTask, getSettings } from '../../../src/api' +import { getPluginSchema, listPlugins, startTask, getSettings, listTargetPolicies, listCredentialProfiles, listSessionProfiles } from '../../../src/api' import { routes } from '../../../src/routes' const addToast = vi.fn() @@ -16,6 +16,9 @@ vi.mock('../../../src/api', () => ({ getPluginSchema: vi.fn(), startTask: vi.fn(), getSettings: vi.fn(), + listTargetPolicies: vi.fn(), + listCredentialProfiles: vi.fn(), + listSessionProfiles: vi.fn(), })) describe('ToolConfig timeout control', () => { @@ -60,6 +63,9 @@ describe('ToolConfig timeout control', () => { vi.mocked(getSettings).mockResolvedValue({ sandbox: { default_timeout: 600 } }) vi.mocked(startTask).mockResolvedValue({ task_id: 'task-1', status: 'queued', created_at: 'now', stream_url: '' }) + vi.mocked(listTargetPolicies).mockResolvedValue({ items: [], total: 0 }) + vi.mocked(listCredentialProfiles).mockResolvedValue({ items: [], total: 0 }) + vi.mocked(listSessionProfiles).mockResolvedValue({ items: [], total: 0 }) }) it('renders integer input with constrained min/max', async () => { diff --git a/frontend/testing/unit/pages/Workflows.test.tsx b/frontend/testing/unit/pages/Workflows.test.tsx index 7c304da85..9231eea1f 100644 --- a/frontend/testing/unit/pages/Workflows.test.tsx +++ b/frontend/testing/unit/pages/Workflows.test.tsx @@ -130,7 +130,7 @@ describe('Workflows — create action', () => { name: 'Nightly Scan', schedule_seconds: 7200, enabled: true, - steps: [{ plugin_id: '', inputs: {} }], + steps: [{ plugin_id: '', inputs: {}, execution_context: { scan_profile: 'standard', validation_mode: 'proof', evidence_level: 'standard' } }], }) }) }) @@ -150,18 +150,176 @@ describe('Workflows — run action', () => { }) }) -describe('Workflows — toggle action', () => { +describe('Workflows — create validation: invalid JSON', () => { beforeEach(() => { - vi.mocked(getWorkflows).mockResolvedValue([mockWorkflow]) - vi.mocked(updateWorkflow).mockResolvedValue({ ...mockWorkflow, enabled: false }) + vi.mocked(getWorkflows).mockResolvedValue([]) + vi.mocked(createWorkflow).mockResolvedValue(mockWorkflow) + vi.mocked(createWorkflow).mockClear() + }) + + it('shows inline JSON error when steps field contains malformed JSON', async () => { + const user = userEvent.setup() + renderPage() + await screen.findByText(/No Workflows/i) + + await user.click(screen.getAllByRole('button', { name: /New Workflow/i })[0]) + + const textarea = document.querySelector('textarea') as HTMLTextAreaElement + await user.clear(textarea) + await user.type(textarea, '{{ not valid json }}}') + + await user.type(screen.getByPlaceholderText('My Workflow'), 'Bad Workflow') + await user.click(screen.getByRole('button', { name: /^Create$/i })) + + expect(screen.getByText('Invalid JSON in steps field')).toBeInTheDocument() + expect(vi.mocked(createWorkflow)).not.toHaveBeenCalled() + }) + + it('clears the JSON error when the steps textarea is edited', async () => { + const user = userEvent.setup() + renderPage() + await screen.findByText(/No Workflows/i) + + await user.click(screen.getAllByRole('button', { name: /New Workflow/i })[0]) + + const textarea = document.querySelector('textarea') as HTMLTextAreaElement + await user.clear(textarea) + await user.type(textarea, '{{ bad }') + await user.type(screen.getByPlaceholderText('My Workflow'), 'X') + await user.click(screen.getByRole('button', { name: /^Create$/i })) + expect(screen.getByText('Invalid JSON in steps field')).toBeInTheDocument() + + // Now fix the textarea — error should disappear + await user.clear(textarea) + await user.type(textarea, '[[]]') + expect(screen.queryByText('Invalid JSON in steps field')).not.toBeInTheDocument() + }) +}) + +describe('Workflows — create validation: schedule input', () => { + beforeEach(() => { + vi.mocked(getWorkflows).mockResolvedValue([]) + vi.mocked(createWorkflow).mockResolvedValue(mockWorkflow) + vi.mocked(createWorkflow).mockClear() + }) + + async function openCreateSheet() { + const user = userEvent.setup() + renderPage() + await screen.findByText(/No Workflows/i) + await user.click(screen.getAllByRole('button', { name: /New Workflow/i })[0]) + await user.type(screen.getByPlaceholderText('My Workflow'), 'Test') + return user + } + + it('shows error for a negative schedule value', async () => { + const user = await openCreateSheet() + const scheduleInput = screen.getByPlaceholderText('3600') + await user.clear(scheduleInput) + await user.type(scheduleInput, '-60') + await user.click(screen.getByRole('button', { name: /^Create$/i })) + + expect(screen.getByText(/Schedule must be a positive whole number/i)).toBeInTheDocument() + expect(vi.mocked(createWorkflow)).not.toHaveBeenCalled() + }) + + it('shows error for a zero schedule value', async () => { + const user = await openCreateSheet() + const scheduleInput = screen.getByPlaceholderText('3600') + await user.clear(scheduleInput) + await user.type(scheduleInput, '0') + await user.click(screen.getByRole('button', { name: /^Create$/i })) + + expect(screen.getByText(/Schedule must be a positive whole number/i)).toBeInTheDocument() + expect(vi.mocked(createWorkflow)).not.toHaveBeenCalled() + }) + + it('shows error for a fractional schedule value', async () => { + const user = await openCreateSheet() + const scheduleInput = screen.getByPlaceholderText('3600') + await user.clear(scheduleInput) + await user.type(scheduleInput, '1.5') + await user.click(screen.getByRole('button', { name: /^Create$/i })) + + expect(screen.getByText(/Schedule must be a positive whole number/i)).toBeInTheDocument() + expect(vi.mocked(createWorkflow)).not.toHaveBeenCalled() + }) + + it('submits successfully when schedule is left empty (manual mode)', async () => { + const user = await openCreateSheet() + const scheduleInput = screen.getByPlaceholderText('3600') + await user.clear(scheduleInput) // empty = null = manual + await user.click(screen.getByRole('button', { name: /^Create$/i })) + + expect(vi.mocked(createWorkflow)).toHaveBeenCalledWith( + expect.objectContaining({ schedule_seconds: null }) + ) }) +}) - it('calls updateWorkflow with toggled enabled state', async () => { +describe('Workflows — toggle: both directions and UI state', () => { + beforeEach(() => { + vi.mocked(updateWorkflow).mockClear() + }) + + it('reflects disabled state in badge after toggling an enabled workflow off', async () => { const user = userEvent.setup() + vi.mocked(getWorkflows).mockResolvedValue([mockWorkflow]) // enabled: true + vi.mocked(updateWorkflow).mockResolvedValue({ ...mockWorkflow, enabled: false }) + renderPage() await screen.findByText('Nightly Scan') + + // Before toggle: Enabled badge present, Disable button present + expect(screen.getByText('Enabled')).toBeInTheDocument() + expect(screen.getByTitle('Disable')).toBeInTheDocument() + await user.click(screen.getByTitle('Disable')) expect(vi.mocked(updateWorkflow)).toHaveBeenCalledWith(mockWorkflow.id, { enabled: false }) + + // After toggle: badge switches to Disabled, button title flips to Enable + await waitFor(() => { + expect(screen.getByText('Disabled')).toBeInTheDocument() + expect(screen.getByTitle('Enable')).toBeInTheDocument() + }) + }) + + it('reflects enabled state in badge after toggling a disabled workflow on', async () => { + const user = userEvent.setup() + vi.mocked(getWorkflows).mockResolvedValue([disabledWorkflow]) // enabled: false + vi.mocked(updateWorkflow).mockResolvedValue({ ...disabledWorkflow, enabled: true }) + + renderPage() + await screen.findByText('Disabled Scan') + + expect(screen.getByText('Disabled')).toBeInTheDocument() + expect(screen.getByTitle('Enable')).toBeInTheDocument() + + await user.click(screen.getByTitle('Enable')) + expect(vi.mocked(updateWorkflow)).toHaveBeenCalledWith(disabledWorkflow.id, { enabled: true }) + + await waitFor(() => { + expect(screen.getByText('Enabled')).toBeInTheDocument() + expect(screen.getByTitle('Disable')).toBeInTheDocument() + }) + }) + + it('disables the toggle button while the API call is in flight', async () => { + const user = userEvent.setup() + let resolve!: (v: typeof mockWorkflow) => void + vi.mocked(getWorkflows).mockResolvedValue([mockWorkflow]) + vi.mocked(updateWorkflow).mockReturnValue(new Promise(r => { resolve = r })) + + renderPage() + await screen.findByText('Nightly Scan') + await user.click(screen.getByTitle('Disable')) + + // Button should be disabled while awaiting + expect(screen.getByTitle('Disable')).toBeDisabled() + + // Resolve and confirm it re-enables + resolve({ ...mockWorkflow, enabled: false }) + await waitFor(() => expect(screen.getByTitle('Enable')).not.toBeDisabled()) }) }) @@ -213,3 +371,50 @@ describe('Workflows — delete action', () => { expect(screen.getByText('Nightly Scan')).toBeInTheDocument() }) }) + +describe('Workflows — delete: dialog state machine', () => { + beforeEach(() => { + vi.mocked(getWorkflows).mockResolvedValue([mockWorkflow]) + vi.mocked(deleteWorkflow).mockClear() + }) + + it('shows "Deleting..." and disables buttons while delete is in flight', async () => { + const user = userEvent.setup() + let resolve!: (v: { deleted: boolean }) => void + vi.mocked(deleteWorkflow).mockReturnValue(new Promise(r => { resolve = r })) + + renderPage() + await screen.findByText('Nightly Scan') + await user.click(screen.getByTitle('Delete')) + + const deleteButtons = screen.getAllByRole('button', { name: /^Delete$/i }) + await user.click(deleteButtons[deleteButtons.length - 1]) + + // Loading state: button text changes, both buttons disabled + expect(screen.getByText('Deleting...')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /Cancel/i })).toBeDisabled() + + resolve({ deleted: true }) + await waitFor(() => { + expect(screen.queryByText('Nightly Scan')).not.toBeInTheDocument() + }) + }) + + it('keeps the dialog open and workflow in list when deleteWorkflow rejects', async () => { + const user = userEvent.setup() + vi.mocked(deleteWorkflow).mockRejectedValue(new Error('Server error')) + + renderPage() + await screen.findByText('Nightly Scan') + await user.click(screen.getByTitle('Delete')) + + const deleteButtons = screen.getAllByRole('button', { name: /^Delete$/i }) + await user.click(deleteButtons[deleteButtons.length - 1]) + + // After the rejection: dialog stays, workflow still visible + await waitFor(() => { + expect(screen.getByText(/Delete Workflow/i)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Nightly Scan' })).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/testing/unit/utils/compareFindings.test.ts b/frontend/testing/unit/utils/compareFindings.test.ts new file mode 100644 index 000000000..0b1f3488d --- /dev/null +++ b/frontend/testing/unit/utils/compareFindings.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { + compareFindings, + findingFingerprint, + type ComparableFinding, +} from '../../../src/utils/compareFindings' + +const base = (overrides: Partial<ComparableFinding> = {}): ComparableFinding => ({ + title: 'Open port', + target: '127.0.0.1', + category: 'network', + severity: 'high', + ...overrides, +}) + +describe('findingFingerprint', () => { + it('is stable for same title target category', () => { + const a = base({ title: 'Open Port', target: '127.0.0.1' }) + const b = base({ title: 'open port', target: '127.0.0.1' }) + expect(findingFingerprint(a)).toBe(findingFingerprint(b)) + }) +}) + +describe('compareFindings', () => { + it('detects new findings in comparison scan', () => { + const baseline = [base({ title: 'Old issue' })] + const comparison = [base({ title: 'Old issue' }), base({ title: 'New issue' })] + const result = compareFindings(baseline, comparison) + expect(result.newFindings).toHaveLength(1) + expect(result.newFindings[0].comparison?.title).toBe('New issue') + }) + + it('detects fixed findings removed in comparison scan', () => { + const baseline = [base({ title: 'Fixed issue' }), base({ title: 'Still here' })] + const comparison = [base({ title: 'Still here' })] + const result = compareFindings(baseline, comparison) + expect(result.fixedFindings).toHaveLength(1) + expect(result.fixedFindings[0].baseline?.title).toBe('Fixed issue') + }) + + it('detects unchanged findings with same severity', () => { + const baseline = [base({ title: 'Stable', severity: 'medium' })] + const comparison = [base({ title: 'Stable', severity: 'medium' })] + const result = compareFindings(baseline, comparison) + expect(result.unchangedFindings).toHaveLength(1) + expect(result.severityChanged).toHaveLength(0) + }) + + it('detects severity changes for matching findings', () => { + const baseline = [base({ title: 'Escalated', severity: 'medium' })] + const comparison = [base({ title: 'Escalated', severity: 'critical' })] + const result = compareFindings(baseline, comparison) + expect(result.severityChanged).toHaveLength(1) + expect(result.unchangedFindings).toHaveLength(0) + }) + + it('handles empty findings on both sides', () => { + const result = compareFindings([], []) + expect(result.newFindings).toHaveLength(0) + expect(result.fixedFindings).toHaveLength(0) + expect(result.unchangedFindings).toHaveLength(0) + expect(result.severityChanged).toHaveLength(0) + }) +}) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 64a48f04b..99cdd2ac7 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,7 +18,13 @@ export default defineConfig(({ mode }) => { }, }, }, + optimizeDeps: { + esbuildOptions: { + target: 'es2022', + }, + }, build: { + target: 'es2022', rollupOptions: { output: { manualChunks(id) { diff --git a/frontend/vitest.setup.ts b/frontend/vitest.setup.ts index 7a7a6f7bf..507ae012e 100644 --- a/frontend/vitest.setup.ts +++ b/frontend/vitest.setup.ts @@ -2,7 +2,7 @@ import '@testing-library/jest-dom'; function createStorageMock() { const store = new Map<string, string>(); - return { + const handler = { getItem: (key: string) => (store.has(key) ? store.get(key)! : null), setItem: (key: string, value: string) => { store.set(key, String(value)); @@ -18,6 +18,37 @@ function createStorageMock() { return store.size; }, }; + + return new Proxy(handler, { + get(target, prop) { + if (prop === 'length') { + return store.size; + } + if (typeof prop === 'string' && !isNaN(Number(prop))) { + return Array.from(store.entries())[Number(prop)]?.[1] ?? null; + } + if (prop in target) { + return (target as any)[prop]; + } + return store.get(String(prop)) ?? null; + }, + ownKeys() { + return Array.from(store.keys()); + }, + getOwnPropertyDescriptor(target, prop) { + if (store.has(String(prop))) { + return { + configurable: true, + enumerable: true, + value: store.get(String(prop)), + }; + } + if (prop in target) { + return Object.getOwnPropertyDescriptor(target, prop); + } + return undefined; + }, + }); } if (!window.localStorage || typeof window.localStorage.getItem !== 'function') { diff --git a/output/pdf/secuscan_final_report_2026-03-25.pdf b/output/pdf/secuscan_final_report_2026-03-25.pdf deleted file mode 100644 index dd3f25bc7..000000000 Binary files a/output/pdf/secuscan_final_report_2026-03-25.pdf and /dev/null differ diff --git a/output/pdf/secuscan_final_report_2026-03-25_elegant.pdf b/output/pdf/secuscan_final_report_2026-03-25_elegant.pdf deleted file mode 100644 index eb44b1fd8..000000000 Binary files a/output/pdf/secuscan_final_report_2026-03-25_elegant.pdf and /dev/null differ diff --git a/output/pdf/secuscan_workspace_report.pdf b/output/pdf/secuscan_workspace_report.pdf deleted file mode 100644 index fbee64670..000000000 Binary files a/output/pdf/secuscan_workspace_report.pdf and /dev/null differ diff --git a/plugins/amass/metadata.json b/plugins/amass/metadata.json index 9c2021d63..1ae9f7ccb 100644 --- a/plugins/amass/metadata.json +++ b/plugins/amass/metadata.json @@ -10,7 +10,7 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udd0e", + "icon": "🔎", "engine": { "type": "cli", "binary": "amass" @@ -30,7 +30,8 @@ "label": "Root Domain", "type": "string", "required": true, - "placeholder": "secuscan.in" + "placeholder": "secuscan.in", + "help": "Enter the root domain to enumerate subdomains for (for example: example.com). Do not include http://, https://, paths, or subdomains." } ], "presets": { @@ -55,5 +56,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "d15de85963abb77d529487e5000519ef554171826f37d3a223b44f67725b6312" + "checksum": "5a0ea8ff2a07b965685a2772bb2557afde4ef50e23dc423c99c27ce518494747" } diff --git a/plugins/api_scanner/metadata.json b/plugins/api_scanner/metadata.json index 703c959d1..fe15fac96 100644 --- a/plugins/api_scanner/metadata.json +++ b/plugins/api_scanner/metadata.json @@ -10,7 +10,10 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", + "implementation_status": "native", + "supports_authenticated_crawling": true, + "supports_session_reuse": true, "engine": { "type": "cli", "binary": "nuclei" @@ -29,7 +32,7 @@ "required": true, "placeholder": "https://api.secuscan.in", "validation": { - "pattern": "^https?://", + "validation_type": "url", "message": "Must be a valid HTTP(S) URL" } } @@ -50,6 +53,11 @@ "max_concurrent": 1 } }, + "capabilities": [ + "network", + "intrusive", + "credentials" + ], "dependencies": { "binaries": [ "nuclei" @@ -57,5 +65,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "21271574173b2881599dd103a10c678456031d36fab90cc7b35683413613d154" + "checksum": "d425be32c71b9da140ba95be80564374deac80a2b42592fe392413d1903789f8" } diff --git a/plugins/cloud_storage_auditor/metadata.json b/plugins/cloud_storage_auditor/metadata.json index 67b722134..d3ad28652 100644 --- a/plugins/cloud_storage_auditor/metadata.json +++ b/plugins/cloud_storage_auditor/metadata.json @@ -10,7 +10,7 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", "engine": { "type": "cli", "binary": "uncover" @@ -29,14 +29,16 @@ "label": "Search Query", "type": "string", "required": true, - "placeholder": "s3.amazonaws.com org:example" + "placeholder": "s3.amazonaws.com org:example", + "help": "Enter an Uncover search query to identify exposed cloud storage assets. Examples include bucket names, domains, or organization filters." }, { "id": "limit", "label": "Result Limit", "type": "integer", "required": false, - "default": 100 + "default": 100, + "help": "Maximum number of results to return. Increase for broader discovery or reduce to limit output volume." } ], "presets": { @@ -64,5 +66,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "8327c42108570fc0b7b5379661f811d3c36b26abf904182e6634bd22145b9c7a" + "checksum": "36f2fb411bd249ff428376845c08f98cbb1cfba525f554e396d733edb4106dc7" } diff --git a/plugins/code_analyzer/metadata.json b/plugins/code_analyzer/metadata.json index e9f02ea33..6b8c4a487 100644 --- a/plugins/code_analyzer/metadata.json +++ b/plugins/code_analyzer/metadata.json @@ -31,7 +31,7 @@ "type": "string", "required": true, "placeholder": "/path/to/python_file_or_dir", - "help": "Absolute path to the Python file or directory to scan." + "help": "Absolute path to a Python file or directory. Bandit will recursively analyze Python source code at this location and report potential security issues." }, { "id": "level", @@ -53,7 +53,7 @@ "label": "High" } ], - "help": "Report results at or above this severity level." + "help": "Minimum severity threshold to include in results. Low shows all findings, Medium hides low-severity findings, and High shows only high-severity findings." } ], "presets": { @@ -88,5 +88,5 @@ "system_packages": [] }, "docker_image": "pyfound/bandit:latest", - "checksum": "7daaed25ba96309668e1792c653abeec3f6d407324878b08e1d7a607a5f728db" + "checksum": "efbcbcdc76325f82a5cee1076bebf565ebcbb4237f7542b05ad52169ba7d1321" } diff --git a/plugins/crawler/metadata.json b/plugins/crawler/metadata.json index df72d1423..59c883cfd 100644 --- a/plugins/crawler/metadata.json +++ b/plugins/crawler/metadata.json @@ -2,15 +2,15 @@ "id": "crawler", "name": "Crawler", "version": "1.0.0", - "description": "Recursive web crawler for link discovery.", - "long_description": "Recursive web crawler for link discovery.", + "description": "Depth-limited Katana crawl for recursive link discovery.", + "long_description": "Runs Katana with a configurable crawl depth to recursively discover links from a target URL. Use this for general active crawling where depth control matters.", "category": "robots", "author": { "name": "SecuScan Contributors", "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", "engine": { "type": "cli", "binary": "katana" @@ -31,8 +31,8 @@ "required": true, "placeholder": "https://secuscan.in", "validation": { - "pattern": "^https?://", - "message": "Must be a valid HTTP(S) URL" + "validation_type": "url", + "message": "Must be a valid HTTP or HTTPS URL" } }, { @@ -68,5 +68,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "12ba96873661ba9a316f3b8ab5fd75daf9f66a259e4f4a706cc3895a86ef59d4" + "checksum": "07d6c15303a758622c1f9178c7af2edfd4dfe90110b53fbb02f4d12db9febdd9" } diff --git a/plugins/dir_discovery/metadata.json b/plugins/dir_discovery/metadata.json index cb168cf79..0449cfacb 100644 --- a/plugins/dir_discovery/metadata.json +++ b/plugins/dir_discovery/metadata.json @@ -55,22 +55,22 @@ "label": "Wordlist", "type": "select", "required": false, - "default": "medium", + "default": "small", "options": [ { "value": "small", - "label": "Small (500 entries) - Fast" + "label": "Small (~108 entries, bundled) - Fast & polite" }, { "value": "medium", - "label": "Medium (5,000 entries) - Balanced" + "label": "Medium (~5,000 entries, install from SecLists) - Balanced" }, { "value": "large", - "label": "Large (50,000 entries) - Comprehensive" + "label": "Large (~50,000 entries, install from SecLists) - Comprehensive" } ], - "help": "Discovery dictionary size" + "help": "Discovery dictionary. Only 'small' ships with SecuScan (wordlists/small.txt); 'medium' and 'large' must be installed into the wordlists/ directory yourself (e.g. from SecLists) or the scan fails - see wordlists/README.md. Defaults to the bundled 'small' list for a fast, low-traffic scan." }, { "id": "extensions", @@ -185,5 +185,5 @@ "system_packages": [] }, "docker_image": "secuscan/ffuf", - "checksum": "2c66081a09e2c3a765316987257ce7a46116ccd4c19e18fc66cb6b14b60e0742" + "checksum": "72f7b28148a646d9009d48dbf34d1fa89c7088037cd51d3e5fde2790d54b4928" } diff --git a/plugins/dns_enum/metadata.json b/plugins/dns_enum/metadata.json index b3dba071d..962246e84 100644 --- a/plugins/dns_enum/metadata.json +++ b/plugins/dns_enum/metadata.json @@ -3,7 +3,7 @@ "name": "DNS Reconnaissance", "version": "1.0.0", "description": "Enumerate DNS records and configurations", - "long_description": "DNSRecon is a powerful DNS reconnaissance tool. It can perform various types of DNS security assessments, including zone transfers, reverse lookups, and brute forcing.", + "long_description": "DNSRecon performs DNS reconnaissance against a single registrable domain (for example example.com or scanme.nmap.org). Standard enumeration resolves the domain's A/AAAA, NS, MX and SOA records and groups repeated values into one readable entry per host. Zone Transfer (AXFR) attempts a full zone copy and reports a critical finding when a name server allows it. Brute Force discovers subdomains from a built-in name list. Run it only against domains you own or are explicitly authorized to assess.", "category": "recon", "author": { "name": "SecuScan Contributors", @@ -28,7 +28,7 @@ "type": "string", "required": true, "placeholder": "example.com", - "help": "Target domain for DNS enumeration." + "help": "Registrable domain to enumerate — no scheme or path (e.g. example.com, scanme.nmap.org, or an internal domain such as corp.example). DNSRecon resolves this domain's A/AAAA, NS, MX and SOA records; subdomains are only discovered when Enum Type is set to Brute Force." }, { "id": "type", @@ -50,7 +50,7 @@ "label": "Brute Force Subdomains" } ], - "help": "Type of DNS enumeration to perform." + "help": "What DNSRecon enumerates. Standard returns the domain's A/AAAA, NS, MX and SOA records grouped per host. Zone Transfer (AXFR) attempts a full zone copy and reports a critical finding if the server allows it. Brute Force discovers subdomains from a built-in name list." } ], "presets": { @@ -59,6 +59,9 @@ }, "zone_transfer": { "type": "axfr" + }, + "subdomain_bruteforce": { + "type": "brt" } }, "output": { @@ -88,5 +91,5 @@ ] }, "docker_image": "darkoperator/dnsrecon:latest", - "checksum": "a9d9107ad715c542acc252d495337b6f7de790210e3d300971e1aba2e19260f4" + "checksum": "12cd4e84e7bf888e9b8d5a21d597ca1487f2ab843be78ab367e486bec9b2b761" } diff --git a/plugins/dnsx/metadata.json b/plugins/dnsx/metadata.json index d1126c4e9..0d2af40b4 100644 --- a/plugins/dnsx/metadata.json +++ b/plugins/dnsx/metadata.json @@ -10,7 +10,7 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udd0e", + "icon": "🔎", "engine": { "type": "cli", "binary": "dnsx" @@ -27,7 +27,8 @@ "label": "Domain", "type": "string", "required": true, - "placeholder": "secuscan.in" + "placeholder": "secuscan.in", + "help": "The target domain against which DNS resolution and verification will be performed (e.g., example.com)." } ], "presets": { @@ -52,5 +53,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "8f1a5ecf2f4e2192010470c00853682ab7f70714325cdc8d59c8000a1598f44a" + "checksum": "d7a7914e25e63212882702cc8ff218819dd609e38025e24514dd36cf6670cb68" } diff --git a/plugins/hashcat/metadata.json b/plugins/hashcat/metadata.json index f50e164c7..91e6878a1 100644 --- a/plugins/hashcat/metadata.json +++ b/plugins/hashcat/metadata.json @@ -78,7 +78,12 @@ }, "output": { "format": "text", - "parser": "custom" + "parser": "custom", + "artifacts": [ + "Recovered credential output", + "Hashcat session files", + "Potfile entries" + ] }, "safety": { "level": "exploit", @@ -96,5 +101,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "3c62c9b154b651149426d4fa7fefc9fdfb16f05c20e0850b0ed63a8f44c22b89" + "checksum": "f46f049daf6fad834f77401ca188596b68dec5ff5036b83e2ef013cf01a0d888" } diff --git a/plugins/http_inspector/metadata.json b/plugins/http_inspector/metadata.json index 814b4855a..5a78cc78d 100644 --- a/plugins/http_inspector/metadata.json +++ b/plugins/http_inspector/metadata.json @@ -79,6 +79,9 @@ "max_concurrent": 5 } }, + "capabilities": [ + "network" + ], "learning": { "difficulty": "beginner", "estimated_duration": "30 seconds", @@ -92,5 +95,5 @@ "system_packages": [] }, "docker_image": "curlimages/curl", - "checksum": "435436b8dbde27b30f545c3f5701089db880794a8a3f3a47e08cf2e6dbffa447" + "checksum": "e62fb9a052a9d10b634541d9c3fc72c2c700bb684272324a66ecf75af070ab77" } diff --git a/plugins/http_request_logger/metadata.json b/plugins/http_request_logger/metadata.json index ac8bfe7b4..389b45630 100644 --- a/plugins/http_request_logger/metadata.json +++ b/plugins/http_request_logger/metadata.json @@ -10,7 +10,7 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", "engine": { "type": "cli", "binary": "httpx" @@ -31,6 +31,7 @@ "type": "string", "required": true, "placeholder": "https://secuscan.in", + "help": "Enter the full HTTP or HTTPS URL to query and collect response details such as status code, page title, and web server information.", "validation": { "pattern": "^https?://", "message": "Must be a valid HTTP(S) URL" @@ -60,5 +61,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "1914157eced0f91a400d858291ca4dc5b9fe9c5b286d4178339deb7fc7f4fb06" + "checksum": "f5d0d16d054e258bf928051ef4ee7716171ec533c55e081bfe98533e75781d99" } diff --git a/plugins/httpx/metadata.json b/plugins/httpx/metadata.json index 8a2a7bee0..bbde28b40 100644 --- a/plugins/httpx/metadata.json +++ b/plugins/httpx/metadata.json @@ -2,15 +2,15 @@ "id": "httpx", "name": "httpx", "version": "1.0.0", - "description": "Live host probing with status, title, and technology fingerprinting.", - "long_description": "Live host probing with status, title, and technology fingerprinting.", + "description": "Probe live hosts and collect reachability information, status codes, page titles, and basic technology indicators.", + "long_description": "Probe live hosts and collect reachability information, status codes, page titles, and basic technology indicators.", "category": "recon", "author": { "name": "SecuScan Contributors", "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udd0e", + "icon": "🔎", "engine": { "type": "cli", "binary": "httpx" @@ -55,5 +55,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "b74defa2b8d5595ae6a8fbd8020c35ce05a214beb65d11f31847ae28d6517e2f" + "checksum": "6570954f2b2cae9ce3a2281445f5c4c46533ada5cd6b5c35859d592523d517e9" } diff --git a/plugins/katana/metadata.json b/plugins/katana/metadata.json index f3e71f407..a39f1a935 100644 --- a/plugins/katana/metadata.json +++ b/plugins/katana/metadata.json @@ -2,15 +2,15 @@ "id": "katana", "name": "Katana", "version": "1.0.0", - "description": "Web crawling for endpoint and route discovery.", - "long_description": "Web crawling for endpoint and route discovery.", + "description": "Baseline Katana URL discovery using the default crawl behavior.", + "long_description": "Runs Katana with minimal flags for lightweight endpoint and route discovery. Use this as the baseline Katana wrapper when no custom crawl depth or JavaScript crawling is required.", "category": "recon", "author": { "name": "SecuScan Contributors", "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udd0e", + "icon": "🔎", "engine": { "type": "cli", "binary": "katana" @@ -25,6 +25,7 @@ { "id": "target", "label": "Target URL", + "help": "Enter the HTTP(S) URL to crawl for endpoint and route discovery.", "type": "string", "required": true, "placeholder": "https://secuscan.in" @@ -53,5 +54,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "6c03736ab9bf88e97d7656ae4816922f68c3ef2a07a66b1ccfa7cb2c570b6a1a" + "checksum": "539bd64f8c8774c54857bfb826f9748ba487dedcba1482a26a5383c1b022d786" } diff --git a/plugins/kubernetes_scanner/metadata.json b/plugins/kubernetes_scanner/metadata.json index 5e324c894..04c597059 100644 --- a/plugins/kubernetes_scanner/metadata.json +++ b/plugins/kubernetes_scanner/metadata.json @@ -27,7 +27,8 @@ "label": "Cluster Context", "type": "string", "required": true, - "placeholder": "prod-cluster" + "placeholder": "prod-cluster", + "help": "Enter the Kubernetes cluster context name from your kubeconfig, for example prod-cluster." } ], "presets": { @@ -53,5 +54,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "830b2c5f4e397d2d2ad28f71ec8d1a3a5f9bd3e439d8318f71a5b8a371f7ccdb" + "checksum": "a86d9187ec7f37dff67dd0c6223a9988138bd2255fc2cfeae0dc14f203630e4c" } diff --git a/plugins/network_scanner/metadata.json b/plugins/network_scanner/metadata.json index 0593f1c67..a327e86a9 100644 --- a/plugins/network_scanner/metadata.json +++ b/plugins/network_scanner/metadata.json @@ -10,7 +10,8 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", + "implementation_status": "native", "engine": { "type": "cli", "binary": "nmap" @@ -58,6 +59,10 @@ "max_concurrent": 1 } }, + "capabilities": [ + "network", + "intrusive" + ], "dependencies": { "binaries": [ "nmap" @@ -65,5 +70,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "54d4478eaeec8b047841ac1ab9971842b02cafa6c5895b82469c2c3e2ce97652" + "checksum": "76e2c3f5d2bb88cc52cd5844e9b9b25a478b9cd05c98acdb4956993314d9eb33" } diff --git a/plugins/nuclei/metadata.json b/plugins/nuclei/metadata.json index e0ec4ea13..bf669ba9e 100644 --- a/plugins/nuclei/metadata.json +++ b/plugins/nuclei/metadata.json @@ -126,9 +126,11 @@ "tutorial_url": "https://docs.secuscan.local/plugins/nuclei" }, "dependencies": { - "binaries": [], + "binaries": [ + "nuclei" + ], "python_packages": [], "system_packages": [] }, - "checksum": "ecdacab5d26ebd95d07fe61cc781768e046ab004438a254f2ed80efd167c728d" + "checksum": "8304cb4226938c354aa831943de4da3dfa57e2fe4cc9c200d448f49b56f74590" } diff --git a/plugins/password_auditor/metadata.json b/plugins/password_auditor/metadata.json index 61ea60717..a31c35e6b 100644 --- a/plugins/password_auditor/metadata.json +++ b/plugins/password_auditor/metadata.json @@ -10,7 +10,7 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", "engine": { "type": "cli", "binary": "python3" @@ -27,7 +27,8 @@ "label": "Target", "type": "string", "required": true, - "placeholder": "secuscan.in" + "placeholder": "secuscan.in", + "help": "Enter the hostname, domain, or service endpoint to review for weak credential and password policy risks." } ], "presets": { @@ -53,5 +54,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "d3606fc8426a11a890874be7159b75b534da6c0f5aa56f8291578feb29934358" + "checksum": "303a6a52375f996d47c526a147b33362af5600d1eaa326497108868ee6b65ea5" } diff --git a/plugins/people-email-discovery/metadata.json b/plugins/people-email-discovery/metadata.json index dc7ccf946..8ae654f09 100644 --- a/plugins/people-email-discovery/metadata.json +++ b/plugins/people-email-discovery/metadata.json @@ -26,6 +26,7 @@ { "id": "target", "label": "Target Domain", + "help": "Enter the domain name to search for associated email addresses and public profiles.", "type": "string", "required": true, "placeholder": "secuscan.in" @@ -53,5 +54,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "0f0889733638a60dc134a94a668c6683cbdb435560d60f61e7735d47114334c6" + "checksum": "b637adaf26113ae2e6225ad26b61bc60bd3777a6c3e8fac58d0ec78b21ebd4e1" } diff --git a/plugins/scapy_recon/metadata.json b/plugins/scapy_recon/metadata.json index 879b86a0a..947907b6c 100644 --- a/plugins/scapy_recon/metadata.json +++ b/plugins/scapy_recon/metadata.json @@ -27,7 +27,7 @@ "type": "string", "required": true, "placeholder": "192.168.1.0/24", - "help": "Target IP address or CIDR range." + "help": "Target IPv4 address or CIDR range. Examples: 192.168.1.10 or 192.168.1.0/24. Only scan networks you own or are authorized to assess." }, { "id": "type", @@ -45,7 +45,7 @@ "label": "ICMP Ping (standard)" } ], - "help": "Method of network discovery." + "help": "Choose ARP Ping for local networks and ICMP Ping for standard host discovery. Use only with proper authorization." } ], "presets": { @@ -86,5 +86,5 @@ ] }, "docker_image": "secdev/scapy:latest", - "checksum": "688f16afba16bcf122728cd46a060717622ffeb96dad29b964593b9025b79682" + "checksum": "172b7870ba6be89b46ec2918c2567b9d0e5be70b719e0e0d404d81b39a7fa18d" } diff --git a/plugins/semgrep_scanner/metadata.json b/plugins/semgrep_scanner/metadata.json new file mode 100644 index 000000000..8c0b8245b --- /dev/null +++ b/plugins/semgrep_scanner/metadata.json @@ -0,0 +1,78 @@ +{ + "id": "semgrep_scanner", + "name": "Semgrep Scanner", + "version": "1.0.0", + "description": "Multi-language static code analysis using Semgrep", + "long_description": "Semgrep is a fast, open-source static analysis tool for finding bugs and enforcing code standards at editor, commit, and CI time.", + "category": "code", + "author": { + "name": "SecuScan Contributors", + "email": "dev@secuscan.local" + }, + "license": "MIT", + "icon": "🔎", + "engine": { + "type": "cli", + "binary": "semgrep" + }, + "command_template": [ + "semgrep", + "scan", + "--json", + "-q", + "--config", + "{config}", + "{target}" + ], + "fields": [ + { + "id": "target", + "label": "Source Path", + "type": "string", + "required": true, + "placeholder": "/path/to/source", + "help": "Absolute path to the directory or file to scan." + }, + { + "id": "config", + "label": "Semgrep Config", + "type": "string", + "required": false, + "default": "auto", + "help": "Semgrep configuration (e.g., auto, p/security-audit, p/ci, or path to config file)." + } + ], + "presets": { + "default": { + "config": "auto" + } + }, + "output": { + "format": "json", + "parser": "custom" + }, + "safety": { + "level": "safe", + "requires_consent": false, + "rate_limit": { + "max_per_hour": 20, + "max_concurrent": 1 + } + }, + "learning": { + "difficulty": "intermediate", + "estimated_duration": "1-5 minutes", + "tutorial_url": "https://semgrep.dev/docs/" + }, + "dependencies": { + "binaries": [ + "semgrep" + ], + "python_packages": [ + "semgrep" + ], + "system_packages": [] + }, + "docker_image": "returntocorp/semgrep:latest", + "checksum": "8e59254f5ad937c0a5a2af47367cddf07e07b8a528d13ff303360d6e478a51d8" +} diff --git a/plugins/semgrep_scanner/parser.py b/plugins/semgrep_scanner/parser.py new file mode 100644 index 000000000..26ab84120 --- /dev/null +++ b/plugins/semgrep_scanner/parser.py @@ -0,0 +1,53 @@ +import json +from typing import Dict, Any + + +def parse(output: str) -> Dict[str, Any]: + """ + Parse Semgrep JSON output. + """ + findings = [] + try: + data = json.loads(output) + results = data.get("results", []) + + # Mapping Semgrep severity to SecuScan severity + severity_map = {"INFO": "info", "WARNING": "medium", "ERROR": "high"} + + for res in results: + check_id = res.get("check_id", "Unknown Rule") + path = res.get("path", "Unknown Path") + extra = res.get("extra", {}) + message = extra.get("message", "No message provided") + semgrep_severity = extra.get("severity", "INFO") + + # Map severity + severity = severity_map.get(semgrep_severity, "low") + + # Extract line info + start_info = res.get("start", {}) + line_no = start_info.get("line", 0) + + # Code snippet as evidence + lines = extra.get("lines", "") + + findings.append( + { + "title": f"Semgrep issue: {check_id} in {path}", + "category": "Code Security", + "severity": severity, + "description": message, + "remediation": f"Review rule {check_id} and the affected code snippet.", + "metadata": { + "rule_id": check_id, + "file": path, + "line": line_no, + "evidence": lines, + "semgrep_severity": semgrep_severity, + }, + } + ) + except Exception: + pass + + return {"findings": findings, "count": len(findings)} diff --git a/plugins/sharepoint_scanner/metadata.json b/plugins/sharepoint_scanner/metadata.json index 2fb761770..689a100d7 100644 --- a/plugins/sharepoint_scanner/metadata.json +++ b/plugins/sharepoint_scanner/metadata.json @@ -10,7 +10,7 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", "engine": { "type": "cli", "binary": "nuclei" @@ -28,6 +28,7 @@ "type": "string", "required": true, "placeholder": "https://sharepoint.secuscan.in", + "help": "Enter the full SharePoint site URL to scan for security issues, misconfigurations, and exposed resources.", "validation": { "pattern": "^https?://", "message": "Must be a valid HTTP(S) URL" @@ -57,5 +58,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "ba08df50150ea7d4df2784f4a5ba4d257a1669dd98d532f4fab6d84edc83fb0a" + "checksum": "5b12fe52ee58960818ec99ed5474703682075de7bf090bcd1e9288c9c4397806" } diff --git a/plugins/sitemap_gen/metadata.json b/plugins/sitemap_gen/metadata.json index 163b50d36..68e283037 100644 --- a/plugins/sitemap_gen/metadata.json +++ b/plugins/sitemap_gen/metadata.json @@ -2,15 +2,15 @@ "id": "sitemap_gen", "name": "Sitemap Generator", "version": "1.0.0", - "description": "Build complete XML sitemaps by autonomously parsing targets.", - "long_description": "Build complete XML sitemaps by autonomously parsing targets.", + "description": "Depth-focused Katana crawl for sitemap-style URL inventory.", + "long_description": "Runs Katana with a deeper default crawl depth to produce a sitemap-style inventory of discovered URLs. This does not generate XML; it returns discovered URLs for cataloging target surface area.", "category": "robots", "author": { "name": "SecuScan Contributors", "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", "engine": { "type": "cli", "binary": "katana" @@ -27,6 +27,7 @@ { "id": "target", "label": "Target URL", + "help": "Enter the HTTP(S) URL to crawl and generate a sitemap from.", "type": "string", "required": true, "placeholder": "https://secuscan.in", @@ -38,6 +39,7 @@ { "id": "depth", "label": "Depth", + "help": "Maximum crawl depth. Higher values discover more pages but may take longer to complete.", "type": "integer", "required": false, "default": 4 @@ -68,5 +70,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "1555d4793ba9f18eaec55b737fcf28ca7f4236d2b253b1637485ae952409d512" + "checksum": "e65c7ee56ef634d35f5cd13877ab2d69858e102398361acd8fd09a17f110f349" } diff --git a/plugins/sniper/metadata.json b/plugins/sniper/metadata.json index 0f82dd943..c122490bf 100644 --- a/plugins/sniper/metadata.json +++ b/plugins/sniper/metadata.json @@ -10,7 +10,8 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", + "implementation_status": "placeholder", "engine": { "type": "cli", "binary": "python3" @@ -46,6 +47,11 @@ "max_concurrent": 1 } }, + "capabilities": [ + "network", + "intrusive", + "exploit" + ], "dependencies": { "binaries": [ "python3" @@ -53,5 +59,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "fe09afd19995b8a925c2be8e35ec10779925a58380991d904b7fa1a6d46d3f87" + "checksum": "63d3bff66966b33e4812305b631e08130306ad1f0360957f47ecb104bd769565" } diff --git a/plugins/spider/metadata.json b/plugins/spider/metadata.json index e18599a02..6fe705e29 100644 --- a/plugins/spider/metadata.json +++ b/plugins/spider/metadata.json @@ -2,15 +2,15 @@ "id": "spider", "name": "Spider", "version": "1.0.0", - "description": "Advanced web spider with JS execution support.", - "long_description": "Advanced web spider with JS execution support.", + "description": "JavaScript-aware Katana spider for deeper client-side route discovery.", + "long_description": "Runs Katana with JavaScript crawling enabled and a configurable depth. Use this for applications where client-side links and JavaScript-rendered routes may expose additional paths.", "category": "robots", "author": { "name": "SecuScan Contributors", "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", "engine": { "type": "cli", "binary": "katana" @@ -30,6 +30,7 @@ "label": "Target URL", "type": "string", "required": true, + "help": "Enter the full website URL to scan, including the http:// or https:// protocol.", "placeholder": "https://secuscan.in", "validation": { "pattern": "^https?://", @@ -41,6 +42,7 @@ "label": "Depth", "type": "integer", "required": false, + "help": "Range of crawl depth: 1-10. Higher values scan deeper but take longer. Default: 3. Recommended: 1-5.", "default": 3 } ], @@ -69,5 +71,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "8b0111447c9ebc7d1487e2a8fafd4b2ee9c23e191eab39a124e372632b288254" + "checksum": "8f16aed623f771cf396ea9ef778055da31ca2f88f6f70dde9b1cb9cfeeb58499" } diff --git a/plugins/sqli_checker/metadata.json b/plugins/sqli_checker/metadata.json index c9c8f4f40..194445018 100644 --- a/plugins/sqli_checker/metadata.json +++ b/plugins/sqli_checker/metadata.json @@ -2,8 +2,8 @@ "id": "sqli_checker", "name": "SQL Injection Feasibility", "version": "1.0.0", - "description": "SQL injection feasibility scanner powered by Ghauri.", - "long_description": "Runs a controlled SQLi feasibility check against a URL parameter and reports injectable indicators.", + "description": "Validates potential SQL injection vulnerabilities using Ghauri.", + "long_description": "Performs controlled feasibility checks to identify potential SQL injection indicators without focusing on exploitation or data extraction.", "category": "expert", "author": { "name": "SecuScan Contributors", @@ -124,5 +124,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "c6fd02c9a458bf582009d1b4838384afb07a64b1a3837fb8d93f6cc3d8dd4dfa" + "checksum": "62ed5d57c77eaff266ec03e8aa81f2325d2e24d6e71dc2f657072b0df5c64354" } diff --git a/plugins/sqli_exploiter/metadata.json b/plugins/sqli_exploiter/metadata.json index 5d380e0b9..37dfc95fa 100644 --- a/plugins/sqli_exploiter/metadata.json +++ b/plugins/sqli_exploiter/metadata.json @@ -2,8 +2,8 @@ "id": "sqli_exploiter", "name": "SQLi Exploiter", "version": "1.0.0", - "description": "Exploit SQL injection in web apps to extract data.", - "long_description": "Exploit SQL injection in web apps to extract data.", + "description": "SQL injection exploitation and data extraction workflow powered by sqlmap.", + "long_description": "Performs exploitation-focused SQL injection workflows, including database enumeration and data extraction from confirmed SQL injection findings.", "category": "exploit", "author": { "name": "SecuScan Contributors", @@ -83,5 +83,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "afc0b21532397682b6d160ebeac0f8d4830f0f95cdde362b2b384a12a38de596" + "checksum": "027f567c5a99112334dce179a67b53d406e5e16eb1b08fee901c98f2683dea4f" } diff --git a/plugins/sqlmap/metadata.json b/plugins/sqlmap/metadata.json index 160cb5003..39eb31e18 100644 --- a/plugins/sqlmap/metadata.json +++ b/plugins/sqlmap/metadata.json @@ -2,8 +2,8 @@ "id": "sqlmap", "name": "SQL Injection Testing", "version": "1.0.0", - "description": "Automatic SQL injection and database takeover tool", - "long_description": "SQLMap is an open source penetration testing tool that automates the process of detecting and exploiting SQL injection flaws and taking over of database servers.", + "description": "SQL injection detection and database enumeration tool.", + "long_description": "Uses sqlmap to detect SQL injection vulnerabilities and perform controlled database enumeration during assessment workflows.", "category": "web", "author": { "name": "SecuScan Contributors", @@ -120,9 +120,11 @@ "tutorial_url": "https://docs.secuscan.local/plugins/sqlmap" }, "dependencies": { - "binaries": [], + "binaries": [ + "sqlmap" + ], "python_packages": [], "system_packages": [] }, - "checksum": "7312d73eb00289dfe5bc1c2cdb6491054a3a348f198a65b36f69f125c9ae3d50" + "checksum": "efe8eef95714a4363b68618c000bcec5d5e93b98c7b1168775ffaa98fc66eb8a" } diff --git a/plugins/subdomain-finder/metadata.json b/plugins/subdomain-finder/metadata.json deleted file mode 100644 index fa5288c22..000000000 --- a/plugins/subdomain-finder/metadata.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "id": "subdomain-finder", - "name": "Subdomain Finder", - "version": "1.0.0", - "description": "Discover subdomains of a domain.", - "long_description": "Discover subdomains of a domain.", - "category": "recon", - "author": { - "name": "SecuScan Contributors", - "email": "dev@secuscan.local" - }, - "license": "MIT", - "icon": "\ud83d\udd0e", - "engine": { - "type": "cli", - "binary": "subfinder" - }, - "command_template": [ - "subfinder", - "-d", - "{target}", - "-silent" - ], - "fields": [ - { - "id": "target", - "label": "Root Domain", - "type": "string", - "required": true, - "placeholder": "secuscan.in" - } - ], - "presets": { - "default": {} - }, - "output": { - "format": "text", - "parser": "custom" - }, - "safety": { - "level": "safe", - "requires_consent": false, - "rate_limit": { - "max_per_hour": 20, - "max_concurrent": 1 - } - }, - "dependencies": { - "binaries": [ - "subfinder" - ], - "python_packages": [], - "system_packages": [] - }, - "checksum": "4570d8047a567282f230d970a59aa2c6d5bbbcfb37dfd4b9182fc9a0ac8172ea" -} diff --git a/plugins/subdomain-finder/parser.py b/plugins/subdomain-finder/parser.py deleted file mode 100644 index b386a82c5..000000000 --- a/plugins/subdomain-finder/parser.py +++ /dev/null @@ -1,45 +0,0 @@ -import re -from typing import Any, Dict, List - -def parse(output: str) -> Dict[str, Any]: - lines = [line.strip() for line in output.splitlines() if line.strip()] - findings: List[Dict[str, Any]] = [] - discovery_rows = [] - - # Regex to capture subdomain and optionally an IP address following it - # Expected: "backend.utksh.bar 52.0.200.63" or just "backend.utksh.bar" - subdomain_re = re.compile(r"([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(\s+[\d\.]+)?") - - for line in lines: - if match := subdomain_re.search(line): - subdomain, ip = match.groups() - ip = ip.strip() if ip else "-" - - discovery_rows.append({ - "subdomain": subdomain, - "ip": ip, - "service": "Found via Recon", - "state": "Live" - }) - - total_results = len(discovery_rows) - - if total_results > 0: - findings.append({ - "title": f"Discovery: {total_results} Subdomains Identified", - "category": "Recon", - "severity": "info", - "description": f"Identified {total_results} subdomains for the target. Expand results table for full details.", - "remediation": "Audit the necessity of these endpoints. Ensure sensitive subdomains (stg, dev, internal) are not publicly exposed.", - "metadata": {"discovered_count": total_results}, - }) - - return { - "findings": findings, - "count": len(findings), - "structured": { - "rows": discovery_rows, - "type": "subdomains", - "total_count": total_results - } - } diff --git a/plugins/subdomain_discovery/metadata.json b/plugins/subdomain_discovery/metadata.json index edfe60e6c..53f8d39c7 100644 --- a/plugins/subdomain_discovery/metadata.json +++ b/plugins/subdomain_discovery/metadata.json @@ -2,8 +2,8 @@ "id": "subdomain_discovery", "name": "Subdomain Scanner", "version": "1.0.0", - "description": "Enumerate subdomains using passive sources", - "long_description": "Subfinder is a subdomain discovery tool that returns valid subdomains for websites by using passive online sources. It has a simple modular architecture and is optimized for speed.", + "description": "Comprehensive configurable subdomain enumeration via passive sources.", + "long_description": "An advanced subdomain discovery plugin built on ProjectDiscovery's subfinder with additional configuration options. Supports tuning thread count, toggling all passive sources on/off, and selecting between quick and comprehensive presets. Use this plugin when you need fine-grained control over the enumeration depth and source coverage.", "category": "recon", "author": { "name": "SecuScan Contributors", @@ -88,5 +88,5 @@ "system_packages": [] }, "docker_image": "projectdiscovery/subfinder:latest", - "checksum": "34c426cb7ea665b795595723b7f6f0b4bd302ebd69971268ee9eebde4fbac5d5" + "checksum": "36a62951d26ba2da37b777b21fc117f81b4125e88144688f43cc1beb0989cc0c" } diff --git a/plugins/subfinder/metadata.json b/plugins/subfinder/metadata.json index e62a3f73d..4d0c0bc3e 100644 --- a/plugins/subfinder/metadata.json +++ b/plugins/subfinder/metadata.json @@ -2,8 +2,8 @@ "id": "subfinder", "name": "Subfinder", "version": "1.0.0", - "description": "Fast passive subdomain enumeration.", - "long_description": "Fast passive subdomain enumeration.", + "description": "Quick passive subdomain enumeration with minimal configuration.", + "long_description": "A lightweight subdomain discovery plugin using ProjectDiscovery's subfinder. Optimized for speed with a single input — just provide the root domain. Best for quick recon where you want results fast without tuning parameters.", "category": "recon", "author": { "name": "SecuScan Contributors", @@ -52,5 +52,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "a1cb24265eea66c6059544857e22a5a5cd6c4fc0c1049b329f1b1f970d516312" + "checksum": "f9046380e1c3a3f6d516b1d1afb7ababcc2aa23ca0d0e74e928403bda0b881f4" } diff --git a/plugins/uncover/metadata.json b/plugins/uncover/metadata.json index 451b82023..e9b744271 100644 --- a/plugins/uncover/metadata.json +++ b/plugins/uncover/metadata.json @@ -29,14 +29,16 @@ "label": "Search Query", "type": "string", "required": true, - "placeholder": "org:secuscan.in" + "placeholder": "org:secuscan.in", + "help": "Enter search queries using Uncover syntax. Supported operators: org:, domain:, port:, asn:, etc. Example: 'org:secuscan.in' searches for assets belonging to that organization." }, { "id": "limit", "label": "Result Limit", "type": "integer", "required": false, - "default": 100 + "default": 100, + "help": "Maximum number of results to fetch(default: 100). Higher limits may increase query time and resource usage." } ], "presets": { @@ -63,5 +65,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "f66a6bcd537fe6a4c23d38fe7daeac3174e088c158de8dbf98230dd7891c5026" + "checksum": "db1e801d6a027a09ce9c726d593f9dda4b79ab2775a5e824bdeb43df3ba9364d" } diff --git a/plugins/url-fuzzer-2/metadata.json b/plugins/url-fuzzer-2/metadata.json index f77673c2d..ebe151ba7 100644 --- a/plugins/url-fuzzer-2/metadata.json +++ b/plugins/url-fuzzer-2/metadata.json @@ -33,7 +33,7 @@ "required": true, "placeholder": "https://secuscan.in", "validation": { - "pattern": "^https?://", + "validation_type": "url", "message": "Must be a valid HTTP(S) URL" } }, @@ -74,5 +74,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "92487e75582ea0ca9680f5626a2aa93bcb8ecc72c64a35a78f8704175033ddc5" + "checksum": "4fada9f7e171899e44ddccdb66be22500f086a7afe1c69b46a01c16e5803e66c" } diff --git a/plugins/virtual-host-finder/metadata.json b/plugins/virtual-host-finder/metadata.json index eb03eec05..34aa394b6 100644 --- a/plugins/virtual-host-finder/metadata.json +++ b/plugins/virtual-host-finder/metadata.json @@ -33,14 +33,16 @@ "label": "Target Host/IP", "type": "string", "required": true, - "placeholder": "secuscan.in" + "placeholder": "secuscan.in", + "help": "Enter the target domain or IP address to probe for virtual hosts. Example: secuscan.in or 192.168.1.1. Ensure you have authorization before scanning." }, { "id": "wordlist", "label": "Subdomain Wordlist", "type": "string", "required": false, - "default": "/usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt" + "default": "/usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt", + "help": "Absolute path to a wordlist file containing subdomain names to fuzz. Defaults to the SecLists top 1 million subdomains list. Use a smaller list for faster scans." } ], "presets": { @@ -66,5 +68,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "4f1b41fc1b9a02462f05122f660660cb096b8762a9a7986064e4acf6da9c5175" + "checksum": "fb787f5f51da9f62c7a87d04694d5fa8624b0cf656f0d464436c7a8de45a58df" } diff --git a/plugins/volatility/metadata.json b/plugins/volatility/metadata.json index 125555fc2..6a408dde2 100644 --- a/plugins/volatility/metadata.json +++ b/plugins/volatility/metadata.json @@ -10,7 +10,7 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83e\udde0", + "icon": "🧠", "engine": { "type": "cli", "binary": "volatility3" @@ -27,7 +27,8 @@ "label": "Memory Dump Path", "type": "string", "required": true, - "placeholder": "/tmp/memdump.raw" + "placeholder": "/tmp/memdump.raw", + "help": "Absolute path to the raw memory image file to analyse (e.g. /tmp/memdump.raw). Supported formats include .raw, .mem, .vmem, and .lime." }, { "id": "plugin_name", @@ -66,5 +67,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "b4c467aea9e0da68860eb34289de63f16d8fb03851cb5e85c1895345e3d892fc" + "checksum": "733f04bb43785f9b3350876fe9bd9c88e3294b58f540175877d34636091f1df9" } diff --git a/plugins/waf-detection/metadata.json b/plugins/waf-detection/metadata.json deleted file mode 100644 index bd07a22d6..000000000 --- a/plugins/waf-detection/metadata.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "id": "waf-detection", - "name": "WAF Detector", - "version": "1.0.0", - "description": "Fingerprint the Web Application Firewall behind target app.", - "long_description": "Fingerprint the Web Application Firewall behind target app.", - "category": "recon", - "author": { - "name": "SecuScan Contributors", - "email": "dev@secuscan.local" - }, - "license": "MIT", - "icon": "\ud83d\udd0e", - "engine": { - "type": "cli", - "binary": "wafw00f" - }, - "command_template": [ - "wafw00f", - "{target}" - ], - "fields": [ - { - "id": "target", - "label": "Target URL", - "type": "string", - "required": true, - "placeholder": "https://secuscan.in", - "validation": { - "pattern": "^https?://", - "message": "Must be a valid HTTP(S) URL" - } - } - ], - "presets": { - "default": {} - }, - "output": { - "format": "text", - "parser": "custom" - }, - "safety": { - "level": "safe", - "requires_consent": false, - "rate_limit": { - "max_per_hour": 20, - "max_concurrent": 1 - } - }, - "dependencies": { - "binaries": [ - "wafw00f" - ], - "python_packages": [], - "system_packages": [] - }, - "checksum": "018805888afabfb26b74dfe7bc04b5fd99507c0db53eec802bf7381b6ccff12d" -} diff --git a/plugins/waf-detection/parser.py b/plugins/waf-detection/parser.py deleted file mode 100644 index 4b9d02616..000000000 --- a/plugins/waf-detection/parser.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Any, Dict, List - - -def parse(output: str) -> Dict[str, Any]: - lines = [line.strip() for line in output.splitlines() if line.strip()] - findings: List[Dict[str, Any]] = [] - - for line in lines[:200]: - normalized = line.lower() - severity = "info" - if any(keyword in normalized for keyword in ["vuln", "vulnerable", "exposed", "open", "found", "detected", "alive"]): - severity = "low" - - findings.append({ - "title": "WAF Detector Observation", - "category": "Recon", - "severity": severity, - "description": line, - "remediation": "Review discovery output and validate scope and exposure.", - "metadata": {"raw_line": line}, - }) - - return { - "findings": findings, - "count": len(findings), - "items": lines[:200], - } diff --git a/plugins/website-recon-2/metadata.json b/plugins/website-recon-2/metadata.json index f5865d5a3..b1ad20a7f 100644 --- a/plugins/website-recon-2/metadata.json +++ b/plugins/website-recon-2/metadata.json @@ -2,15 +2,15 @@ "id": "website-recon-2", "name": "Website Recon", "version": "1.0.0", - "description": "Fingerprint web technologies of target website.", - "long_description": "Fingerprint web technologies of target website.", + "description": "Perform website reconnaissance focused on identifying web technologies, frameworks, and application stack details.", + "long_description": "Perform website reconnaissance focused on identifying web technologies, frameworks, and application stack details.", "category": "recon", "author": { "name": "SecuScan Contributors", "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udd0e", + "icon": "🔎", "engine": { "type": "cli", "binary": "httpx" @@ -60,5 +60,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "53ac15d9af192a5ac70225f2faaf1f3c086868ea67438d9b588de6645555ef01" + "checksum": "4f8cf37fc9c3de4cdab5d4cb140c619f11bbf76ca6926aa6c41288168e8637f4" } diff --git a/plugins/whois_lookup/metadata.json b/plugins/whois_lookup/metadata.json index f4c942fae..92d438df5 100644 --- a/plugins/whois_lookup/metadata.json +++ b/plugins/whois_lookup/metadata.json @@ -51,12 +51,14 @@ "tutorial_url": "https://docs.secuscan.local/plugins/whois_lookup" }, "dependencies": { - "binaries": [], + "binaries": [ + "python3" + ], "python_packages": [ "python-whois" ], "system_packages": [] }, "docker_image": "alpine:latest", - "checksum": "ecda30e7a979e1a0200d6f58c6c01fe6bae7dbe985cc0f47c5047165c46f3a53" + "checksum": "6d4436abc7bb499843ec8d46a7100565631ff35a368885017c4a5f3136fbfaa1" } diff --git a/plugins/xss_exploiter/metadata.json b/plugins/xss_exploiter/metadata.json index aab838571..879592359 100644 --- a/plugins/xss_exploiter/metadata.json +++ b/plugins/xss_exploiter/metadata.json @@ -10,7 +10,10 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", + "implementation_status": "native", + "supports_authenticated_crawling": true, + "supports_session_reuse": true, "engine": { "type": "cli", "binary": "python3" @@ -50,6 +53,12 @@ "max_concurrent": 1 } }, + "capabilities": [ + "network", + "intrusive", + "exploit", + "credentials" + ], "dependencies": { "binaries": [ "python3" @@ -57,5 +66,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "0a720290e3991401b7a44becae9bb9c748ad782ae57d89443847c59913d10aab" + "checksum": "affa1a035126b9f30bda2b6db7a596fe47a3c4dd21e8025b11cb998bcef6bf57" } diff --git a/plugins/yara_scan/metadata.json b/plugins/yara_scan/metadata.json index 26512737d..e442c01b5 100644 --- a/plugins/yara_scan/metadata.json +++ b/plugins/yara_scan/metadata.json @@ -10,7 +10,7 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udd2c", + "icon": "🔬", "engine": { "type": "cli", "binary": "yara" @@ -36,14 +36,16 @@ "label": "YARA Rules Path", "type": "string", "required": true, - "placeholder": "/opt/rules/malware.yar" + "placeholder": "/opt/rules/malware.yar", + "help": "Path to a YARA rule file (.yar or .yara) used to match signatures against the target." }, { "id": "print_strings", "label": "Print Matching Strings", "type": "boolean", "required": false, - "default": false + "default": false, + "help": "Include matching string values from triggered YARA rules in the scan output." } ], "presets": { @@ -71,5 +73,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "411d3d894c61a405a16dcbe86ae9075720922b10e238161284def026f5e0baac" + "checksum": "5cbd915870a7bae34f6d1b9d960e654221a0236e805a300024f38297be956ac5" } diff --git a/plugins/zap_scanner/metadata.json b/plugins/zap_scanner/metadata.json index 45ee7da39..44d76ec32 100644 --- a/plugins/zap_scanner/metadata.json +++ b/plugins/zap_scanner/metadata.json @@ -10,7 +10,10 @@ "email": "dev@secuscan.local" }, "license": "MIT", - "icon": "\ud83d\udee0\ufe0f", + "icon": "🛠️", + "implementation_status": "integrated", + "supports_authenticated_crawling": true, + "supports_session_reuse": true, "engine": { "type": "cli", "binary": "python3" @@ -50,6 +53,13 @@ "max_concurrent": 1 } }, + "capabilities": [ + "network", + "intrusive", + "exploit", + "docker", + "credentials" + ], "dependencies": { "binaries": [ "python3" @@ -57,5 +67,5 @@ "python_packages": [], "system_packages": [] }, - "checksum": "90b303f8523cee4e707be0003b9506dbb443d3e90ab41cd8f9c59f1e2654fe55" + "checksum": "6546eca4549d40f188c84e9be46b7e992aa6a025d25a9e7ba1e2420387108819" } diff --git a/scripts/check-artifacts.sh b/scripts/check-artifacts.sh index 8485dd377..b0b92d0d9 100644 --- a/scripts/check-artifacts.sh +++ b/scripts/check-artifacts.sh @@ -8,6 +8,12 @@ BLOCKED_PATTERNS=( "frontend/.vite/" "frontend/node_modules/" ".vite/deps/" + "output/" + "data/raw/" + "data/reports/" + "backend/data/raw/" + "backend/data/reports/" + "logs/" ) # ── Check 1: files already tracked in git history ───────────────────────────── @@ -17,6 +23,8 @@ echo "Checking for tracked generated artifacts in git history..." TRACKED_FOUND=() for pattern in "${BLOCKED_PATTERNS[@]}"; do while IFS= read -r match; do + # Allow .gitkeep placeholder files — they are intentional empty markers + [[ "$match" == *".gitkeep" ]] && continue TRACKED_FOUND+=("$match") done < <(git ls-files "${pattern}" 2>/dev/null || true) done @@ -35,7 +43,7 @@ fi # ── Check 2: files newly added in this PR/branch ────────────────────────────── echo "Checking for generated artifacts in PR diff..." if git rev-parse --verify "${BASE_BRANCH}" >/dev/null 2>&1; then - CHANGED_FILES=$(git diff --name-only "${BASE_BRANCH}"...HEAD 2>/dev/null || git diff --name-only HEAD) + CHANGED_FILES=$(git diff --name-only --diff-filter=A "${BASE_BRANCH}"...HEAD 2>/dev/null || git diff --name-only --cached) else CHANGED_FILES=$(git diff --name-only --cached) fi @@ -43,6 +51,7 @@ fi FOUND=() for pattern in "${BLOCKED_PATTERNS[@]}"; do while IFS= read -r match; do + [[ "$match" == *".gitkeep" ]] && continue FOUND+=("$match") done < <(echo "$CHANGED_FILES" | grep -E "^${pattern}" 2>/dev/null || true) done diff --git a/scripts/check_npm_audit.py b/scripts/check_npm_audit.py new file mode 100644 index 000000000..672c4ff1b --- /dev/null +++ b/scripts/check_npm_audit.py @@ -0,0 +1,337 @@ +#!/usr/bin/env python3 +""" +Parse npm audit JSON report and enforce policy. + +Usage: + python scripts/check_npm_audit.py \ + --report frontend/npm-audit-report.json \ + --config .audit-config.yaml \ + --min-severity high +""" + +import json +import sys +import argparse +import re +from pathlib import Path +import datetime +import yaml +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Severity ordering +SEVERITY_LEVELS = { + "critical": 4, + "high": 3, + "moderate": 2, + "medium": 2, + "low": 1, + "info": 1, + "unknown": 1, +} + +def load_config(config_file: str) -> dict: + """Load audit configuration""" + try: + with open(config_file) as f: + return yaml.safe_load(f) or {} + except FileNotFoundError: + logger.warning(f"Config file not found: {config_file}, using defaults") + except yaml.YAMLError as e: + logger.error(f"Malformed config file {config_file}: {e}, using defaults") + + return { + "policy": { + "min_severity_to_block": "high", + "enforce_expiry": True, + "warn_before_expiry_days": 14, + }, + "exceptions": {}, + } + +def load_npm_audit_report(report_file: str) -> dict: + """Load npm audit JSON report""" + with open(report_file) as f: + return json.load(f) + +def is_exception_valid(exception: dict, current_date: datetime.datetime = None) -> bool: + """Check if exception is still valid (not expired)""" + if current_date is None: + current_date = datetime.datetime.now(datetime.timezone.utc) + else: + if current_date.tzinfo is None: + current_date = current_date.replace(tzinfo=datetime.timezone.utc) + + if "expires_at" not in exception: + logger.warning("Exception missing expires_at; treating as expired") + return False + + expires_at = exception["expires_at"] + if expires_at is None: + logger.warning("Exception expires_at is null; treating as expired") + return False + + try: + if isinstance(expires_at, str): + if len(expires_at) == 10: + expiry_date = datetime.date.fromisoformat(expires_at) + expiry = datetime.datetime.combine(expiry_date, datetime.time.max, tzinfo=datetime.timezone.utc) + else: + expiry = datetime.datetime.fromisoformat(expires_at) + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=datetime.timezone.utc) + elif isinstance(expires_at, datetime.datetime): + expiry = expires_at + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=datetime.timezone.utc) + elif isinstance(expires_at, datetime.date): + expiry = datetime.datetime.combine(expires_at, datetime.time.max, tzinfo=datetime.timezone.utc) + else: + logger.error(f"Unknown type for expires_at: {type(expires_at)}") + return False + + return current_date < expiry + except Exception as e: + logger.error(f"Error parsing expiry date '{expires_at}': {e}") + return False + +def check_expiry_warning(exception: dict, warn_days: int, current_date: datetime.datetime = None): + """Warn if exception is close to expiry""" + if current_date is None: + current_date = datetime.datetime.now(datetime.timezone.utc) + else: + if current_date.tzinfo is None: + current_date = current_date.replace(tzinfo=datetime.timezone.utc) + + if "expires_at" not in exception or exception["expires_at"] is None: + return + + expires_at = exception["expires_at"] + try: + if isinstance(expires_at, str): + if len(expires_at) == 10: + expiry_date = datetime.date.fromisoformat(expires_at) + expiry = datetime.datetime.combine(expiry_date, datetime.time.max, tzinfo=datetime.timezone.utc) + else: + expiry = datetime.datetime.fromisoformat(expires_at) + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=datetime.timezone.utc) + elif isinstance(expires_at, datetime.datetime): + expiry = expires_at + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=datetime.timezone.utc) + elif isinstance(expires_at, datetime.date): + expiry = datetime.datetime.combine(expires_at, datetime.time.max, tzinfo=datetime.timezone.utc) + else: + return + + days_until_expiry = (expiry - current_date).days + if 0 < days_until_expiry <= warn_days: + logger.warning( + f"Exception for {exception.get('package', 'unknown')} expires in {days_until_expiry} days! " + f"Expiry: {expires_at}" + ) + except Exception: + pass + +def extract_ghsa_or_cve(issue: dict) -> str: + """Extract GHSA or CVE identifier from npm issue object""" + cwe = issue.get("cwe", []) + cve_id = "UNKNOWN" + if isinstance(cwe, list) and cwe and isinstance(cwe[0], str) and cwe[0]: + cve_id = cwe[0] + + # Try parsing URL for GHSA/CVE + url = issue.get("url", "") + if isinstance(url, str) and url: + match = re.search(r'(GHSA-[a-zA-Z0-9-]+|CVE-\d+-\d+)', url) + if match: + return match.group(1) + + # Try parsing source + source = issue.get("source") + if isinstance(source, str): + match = re.search(r'(GHSA-[a-zA-Z0-9-]+|CVE-\d+-\d+)', source) + if match: + return match.group(1) + + # Try parsing title + title = issue.get("title", "") + if isinstance(title, str) and title: + match = re.search(r'(GHSA-[a-zA-Z0-9-]+|CVE-\d+-\d+)', title) + if match: + return match.group(1) + + # Try mapping integer source + if isinstance(source, int): + if cve_id == "UNKNOWN" or not cve_id: + return f"ADVISORY-{source}" + + return cve_id or "UNKNOWN" + +def main(): + parser = argparse.ArgumentParser(description="Check npm audit report against policy") + parser.add_argument("--report", required=True, help="Path to npm audit JSON report") + parser.add_argument("--config", required=True, help="Path to .audit-config.yaml") + parser.add_argument( + "--min-severity", + choices=["critical", "high", "moderate", "medium", "low"], + default=None, + ) + + args = parser.parse_args() + + # Load config and report + config = load_config(args.config) + policy = config.get("policy", {}) + exceptions = config.get("exceptions", {}) or {} + + # Precedence: argument > config file > default (high) + min_severity_str = args.min_severity or policy.get("min_severity_to_block", "high") + min_severity_str = min_severity_str.lower() + if min_severity_str == "medium": + min_severity_str = "moderate" + + report_path = Path(args.report) + if not report_path.exists() or report_path.stat().st_size == 0: + logger.error(f"Error: Audit report at {args.report} is missing or empty.") + return 1 + + try: + report = load_npm_audit_report(args.report) + except json.JSONDecodeError: + logger.error(f"Invalid JSON in report: {args.report}") + return 1 + + vulnerabilities = report.get("vulnerabilities", {}) + if not vulnerabilities: + logger.info("[OK] No npm vulnerabilities found!") + return 0 + + blocking_count = 0 + warning_count = 0 + excepted_count = 0 + + min_severity_level = SEVERITY_LEVELS.get(min_severity_str, 3) + + for package_name, vuln_data in vulnerabilities.items(): + if not isinstance(vuln_data, dict): + continue + + via_list = vuln_data.get("via", []) + # If via_list is empty or package itself has vulnerability info directly + if not via_list: + # Check package-level severity + severity = vuln_data.get("severity", "unknown").lower() + severity_level = SEVERITY_LEVELS.get(severity, 1) + + # Check package exceptions + is_excepted = False + exception = None + if package_name in exceptions: + exception = exceptions[package_name] + if exception and isinstance(exception, dict): + is_excepted = True + + if is_excepted and exception: + if not is_exception_valid(exception): + if policy.get("enforce_expiry", True): + logger.error(f"Exception for package {package_name} has expired!") + is_excepted = False + else: + logger.warning(f"Exception for package {package_name} has expired, but enforce_expiry is set to false.") + check_expiry_warning(exception, policy.get("warn_before_expiry_days", 14)) + logger.info(f" [OK] {package_name} (package-excepted)") + excepted_count += 1 + continue + else: + check_expiry_warning(exception, policy.get("warn_before_expiry_days", 14)) + logger.info(f" [OK] {package_name} (package-excepted)") + excepted_count += 1 + continue + + if severity_level >= min_severity_level: + blocking_count += 1 + logger.error(f"[FAIL] Package {package_name} is vulnerable ({severity})") + else: + warning_count += 1 + logger.warning(f"[WARN] Package {package_name} is vulnerable ({severity})") + continue + + # Check via items + for issue in via_list: + if isinstance(issue, str): + # Depedency link, usually handled by other keys. We don't want to double block unless we have to. + continue + + if isinstance(issue, dict): + severity = issue.get("severity", "").lower() + cvss = issue.get("cvss", {}) + cve_id = extract_ghsa_or_cve(issue) + + # Check exceptions by CVE ID or package name + is_excepted = False + exception = None + + if cve_id in exceptions: + exception = exceptions[cve_id] + if exception and isinstance(exception, dict): + excepted_package = exception.get("package") + if not excepted_package or excepted_package == package_name: + is_excepted = True + elif package_name in exceptions: + exception = exceptions[package_name] + if exception and isinstance(exception, dict): + is_excepted = True + + if is_excepted and exception: + if not is_exception_valid(exception): + if policy.get("enforce_expiry", True): + logger.error( + f"Exception for {cve_id or package_name} has expired! " + f"Expiry: {exception.get('expires_at')}" + ) + is_excepted = False + else: + logger.warning( + f"Exception for {cve_id or package_name} has expired, but enforce_expiry is set to false. " + f"Expiry: {exception.get('expires_at')}" + ) + check_expiry_warning(exception, policy.get("warn_before_expiry_days", 14)) + logger.info(f" [OK] {cve_id}: {package_name} (excepted)") + excepted_count += 1 + continue + else: + check_expiry_warning(exception, policy.get("warn_before_expiry_days", 14)) + logger.info(f" [OK] {cve_id}: {package_name} (excepted)") + excepted_count += 1 + continue + + severity_level = SEVERITY_LEVELS.get(severity, 1) + if severity_level >= min_severity_level: + blocking_count += 1 + logger.error( + f"[FAIL] {cve_id} ({severity}): {package_name} " + f"(CVSS {cvss.get('score', 'N/A')})" + ) + else: + warning_count += 1 + logger.warning(f"[WARN] {cve_id} ({severity}): {package_name}") + + if excepted_count > 0: + logger.info(f"[OK] {excepted_count} exceptions successfully applied.") + + if warning_count > 0: + logger.warning(f"[WARN] {warning_count} low-severity vulnerabilities detected.") + + if blocking_count > 0: + logger.error(f"[FAIL] {blocking_count} npm vulnerabilities block deployment") + return 1 + + logger.info("[OK] npm audit passed!") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_pip_audit.py b/scripts/check_pip_audit.py new file mode 100644 index 000000000..8a7fff8ef --- /dev/null +++ b/scripts/check_pip_audit.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +""" +Parse pip-audit JSON report and enforce policy. + +Usage: + python scripts/check_pip_audit.py \ + --report backend/pip-audit-report.json \ + --config .audit-config.yaml \ + --min-severity high +""" + +import json +import sys +import argparse +from pathlib import Path +import datetime +import yaml +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Severity ordering +SEVERITY_LEVELS = { + "critical": 4, + "high": 3, + "medium": 2, + "moderate": 2, + "low": 1, + "unknown": 1, +} + +def load_config(config_file: str) -> dict: + """Load audit configuration""" + try: + with open(config_file) as f: + return yaml.safe_load(f) or {} + except FileNotFoundError: + logger.warning(f"Config file not found: {config_file}, using defaults") + except yaml.YAMLError as e: + logger.error(f"Malformed config file {config_file}: {e}, using defaults") + + return { + "policy": { + "min_severity_to_block": "high", + "enforce_expiry": True, + "warn_before_expiry_days": 14, + }, + "exceptions": {}, + "excluded_packages": [], + } + +def load_pip_audit_report(report_file: str) -> dict: + """Load pip-audit JSON report""" + with open(report_file) as f: + return json.load(f) + +def get_cve_id(vuln: dict) -> str: + """Extract CVE ID from vulnerability record""" + # Format: "CVE-2024-12345" or "GHSA-xxxx-xxxx-xxxx" + if "cve" in vuln: + return vuln["cve"] + if "id" in vuln: + return vuln["id"] + return vuln.get("advisory", {}).get("id", "UNKNOWN") + +def is_exception_valid(exception: dict, current_date: datetime.datetime = None) -> bool: + """Check if exception is still valid (not expired)""" + if current_date is None: + current_date = datetime.datetime.now(datetime.timezone.utc) + else: + if current_date.tzinfo is None: + current_date = current_date.replace(tzinfo=datetime.timezone.utc) + + if "expires_at" not in exception: + logger.warning("Exception missing expires_at; treating as expired") + return False + + expires_at = exception["expires_at"] + if expires_at is None: + logger.warning("Exception expires_at is null; treating as expired") + return False + + try: + if isinstance(expires_at, str): + if len(expires_at) == 10: + expiry_date = datetime.date.fromisoformat(expires_at) + expiry = datetime.datetime.combine(expiry_date, datetime.time.max, tzinfo=datetime.timezone.utc) + else: + expiry = datetime.datetime.fromisoformat(expires_at) + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=datetime.timezone.utc) + elif isinstance(expires_at, datetime.datetime): + expiry = expires_at + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=datetime.timezone.utc) + elif isinstance(expires_at, datetime.date): + expiry = datetime.datetime.combine(expires_at, datetime.time.max, tzinfo=datetime.timezone.utc) + else: + logger.error(f"Unknown type for expires_at: {type(expires_at)}") + return False + + return current_date < expiry + except Exception as e: + logger.error(f"Error parsing expiry date '{expires_at}': {e}") + return False + +def check_expiry_warning(exception: dict, warn_days: int, current_date: datetime.datetime = None): + """Warn if exception is close to expiry""" + if current_date is None: + current_date = datetime.datetime.now(datetime.timezone.utc) + else: + if current_date.tzinfo is None: + current_date = current_date.replace(tzinfo=datetime.timezone.utc) + + if "expires_at" not in exception or exception["expires_at"] is None: + return + + expires_at = exception["expires_at"] + try: + if isinstance(expires_at, str): + if len(expires_at) == 10: + expiry_date = datetime.date.fromisoformat(expires_at) + expiry = datetime.datetime.combine(expiry_date, datetime.time.max, tzinfo=datetime.timezone.utc) + else: + expiry = datetime.datetime.fromisoformat(expires_at) + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=datetime.timezone.utc) + elif isinstance(expires_at, datetime.datetime): + expiry = expires_at + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=datetime.timezone.utc) + elif isinstance(expires_at, datetime.date): + expiry = datetime.datetime.combine(expires_at, datetime.time.max, tzinfo=datetime.timezone.utc) + else: + return + + days_until_expiry = (expiry - current_date).days + if 0 < days_until_expiry <= warn_days: + logger.warning( + f"Exception for {exception.get('package', 'unknown')} expires in {days_until_expiry} days! " + f"Expiry: {expires_at}" + ) + except Exception: + pass + +def parse_vulnerabilities(report) -> list: + """Extract all vulnerability structures in a standard format""" + vuls = [] + + # 1. Top-level "vulnerabilities" list + if isinstance(report, dict) and "vulnerabilities" in report and isinstance(report["vulnerabilities"], list): + for v in report["vulnerabilities"]: + if isinstance(v, dict): + vuls.append(v) + + # 2. Dependencies with inner vulns + if isinstance(report, dict) and "dependencies" in report and isinstance(report["dependencies"], list): + for dep in report["dependencies"]: + if not isinstance(dep, dict): + continue + package_name = dep.get("name", "unknown") + version = dep.get("version", "unknown") + vulns = dep.get("vulns", []) + if isinstance(vulns, list): + for v in vulns: + if not isinstance(v, dict): + continue + vuln_record = { + "package": package_name, + "installed_version": version, + "cve": v.get("id") or v.get("cve") or "UNKNOWN", + "aliases": v.get("aliases") or [], + "vulnerability": { + "severity": v.get("severity") or v.get("vulnerability", {}).get("severity") or "unknown", + "advisory": v.get("description") or v.get("advisory") or v.get("vulnerability", {}).get("advisory") or "", + } + } + vuls.append(vuln_record) + + # 3. Top-level list + if isinstance(report, list): + for dep in report: + if not isinstance(dep, dict): + continue + package_name = dep.get("name", "unknown") + version = dep.get("version", "unknown") + vulns = dep.get("vulns", []) + if isinstance(vulns, list): + for v in vulns: + if not isinstance(v, dict): + continue + vuln_record = { + "package": package_name, + "installed_version": version, + "cve": v.get("id") or v.get("cve") or "UNKNOWN", + "aliases": v.get("aliases") or [], + "vulnerability": { + "severity": v.get("severity") or v.get("vulnerability", {}).get("severity") or "unknown", + "advisory": v.get("description") or v.get("advisory") or v.get("vulnerability", {}).get("advisory") or "", + } + } + vuls.append(vuln_record) + + return vuls + +def main(): + parser = argparse.ArgumentParser( + description="Check pip-audit report against policy" + ) + parser.add_argument("--report", required=True, help="Path to pip-audit JSON report") + parser.add_argument("--config", required=True, help="Path to .audit-config.yaml") + parser.add_argument( + "--min-severity", + choices=["critical", "high", "medium", "low"], + default=None, + help="Minimum severity to block CI", + ) + + args = parser.parse_args() + + # Load config and report + config = load_config(args.config) + policy = config.get("policy", {}) + exceptions = config.get("exceptions", {}) or {} + + # Precedence: argument > config file > default (high) + min_severity_str = args.min_severity or policy.get("min_severity_to_block", "high") + min_severity_str = min_severity_str.lower() + + report_path = Path(args.report) + if not report_path.exists() or report_path.stat().st_size == 0: + logger.error(f"Error: Audit report at {args.report} is missing or empty.") + return 1 + + try: + report = load_pip_audit_report(args.report) + except json.JSONDecodeError: + logger.error(f"Invalid JSON in report: {args.report}") + return 1 + + vulnerabilities = parse_vulnerabilities(report) + if not vulnerabilities: + logger.info("[OK] No vulnerabilities found!") + return 0 + + # Filter and enforce policy + blocking_vulns = [] + warning_vulns = [] + excepted_vulns = [] + + min_severity_level = SEVERITY_LEVELS.get(min_severity_str, 3) + + for vuln in vulnerabilities: + cve_id = get_cve_id(vuln) + package = vuln.get("package", "unknown") + version = vuln.get("installed_version", "unknown") + + inner_vuln = vuln.get("vulnerability") or {} + severity = inner_vuln.get("severity") or vuln.get("severity") or "unknown" + severity = severity.lower() + advisory = inner_vuln.get("advisory") or vuln.get("advisory") or "" + + # Check if excepted + is_excepted = False + exception = None + + if cve_id in exceptions: + exception = exceptions[cve_id] + if exception and isinstance(exception, dict): + excepted_package = exception.get("package") + if not excepted_package or excepted_package == package: + is_excepted = True + + if not is_excepted: + aliases = vuln.get("aliases") or [] + if not aliases and isinstance(vuln.get("vulnerability"), dict): + aliases = vuln["vulnerability"].get("aliases") or [] + for alias in aliases: + if alias in exceptions: + exception = exceptions[alias] + if exception and isinstance(exception, dict): + excepted_package = exception.get("package") + if not excepted_package or excepted_package == package: + is_excepted = True + break + + if not is_excepted and package in exceptions: + exception = exceptions[package] + if exception and isinstance(exception, dict): + is_excepted = True + + if is_excepted and exception: + # Validate exception + if not is_exception_valid(exception): + if policy.get("enforce_expiry", True): + logger.error( + f"Exception for {cve_id or package} has expired! " + f"Expiry: {exception.get('expires_at')}" + ) + is_excepted = False + else: + logger.warning( + f"Exception for {cve_id or package} has expired, but enforce_expiry is set to false. " + f"Expiry: {exception.get('expires_at')}" + ) + check_expiry_warning(exception, policy.get("warn_before_expiry_days", 14)) + excepted_vulns.append((cve_id, package, version, exception.get("reason"))) + continue + else: + check_expiry_warning(exception, policy.get("warn_before_expiry_days", 14)) + excepted_vulns.append((cve_id, package, version, exception.get("reason"))) + continue + + # Check severity + severity_level = SEVERITY_LEVELS.get(severity, 1) + if severity_level >= min_severity_level: + blocking_vulns.append((cve_id, package, version, severity, advisory)) + else: + warning_vulns.append((cve_id, package, version, severity, advisory)) + + # Report results + if excepted_vulns: + logger.info(f"[OK] {len(excepted_vulns)} exceptions applied:") + for cve, pkg, ver, reason in excepted_vulns: + logger.info(f" - {cve} ({pkg}=={ver}): {reason}") + + if warning_vulns: + logger.warning(f"[WARN] {len(warning_vulns)} low-severity vulnerabilities found:") + for cve, pkg, ver, sev, adv in warning_vulns: + logger.warning(f" - {cve} ({sev}): {pkg}=={ver}") + + if blocking_vulns: + logger.error(f"[FAIL] {len(blocking_vulns)} vulnerabilities block deployment:") + for cve, pkg, ver, sev, adv in blocking_vulns: + logger.error(f" - {cve} ({sev}): {pkg}=={ver}") + logger.error(f" Advisory: {adv}") + logger.error(f" To exception: Add entry to .audit-config.yaml with expiry date") + + return 1 # Fail CI + + logger.info("[OK] Dependency audit passed!") + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/generate_sbom.py b/scripts/generate_sbom.py new file mode 100644 index 000000000..eb77cb43e --- /dev/null +++ b/scripts/generate_sbom.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +Generate Software Bill of Materials (SBOM) in CycloneDX 1.4 format. + +Usage: + python scripts/generate_sbom.py --output sbom.json +""" + +import json +import subprocess +import sys +import argparse +import re +from pathlib import Path +from typing import Dict, List, Any +from datetime import datetime + +def get_python_packages(requirements_file: str) -> List[Dict[str, str]]: + """Extract Python packages from requirements and resolve their installed versions""" + req_path = Path(requirements_file) + if not req_path.exists(): + return [] + + req_names = set() + with open(req_path) as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + # Extract package name + parts = re.split(r'==|>=|<=|>|<|;|@', line) + if parts: + name = parts[0].strip().lower().replace('_', '-') + if name: + req_names.add(name) + + packages = [] + try: + # Get pip list JSON + result = subprocess.run( + [sys.executable, "-m", "pip", "list", "--format", "json"], + capture_output=True, + text=True, + check=True, + ) + pip_packages = json.loads(result.stdout) + for pkg in pip_packages: + pkg_name = pkg["name"].lower().replace('_', '-') + if pkg_name in req_names: + packages.append({ + "name": pkg["name"], + "version": pkg["version"], + "type": "python-package", + "scope": "runtime" if "dev" not in requirements_file else "development", + }) + except Exception as e: + print(f"Warning: Failed to query pip list: {e}", file=sys.stderr) + + return packages + +def get_npm_packages(package_json_file: str) -> List[Dict[str, str]]: + """Extract npm packages from package.json and resolve versions using npm ls""" + packages = [] + package_json_path = Path(package_json_file) + if not package_json_path.exists(): + return [] + + # Get npm list JSON + try: + result = subprocess.run( + ["npm", "ls", "--json", "--depth=0"], + capture_output=True, + text=True, + cwd=str(package_json_path.parent), + ) + npm_data = json.loads(result.stdout) + deps = npm_data.get("dependencies", {}) + for name, data in deps.items(): + if isinstance(data, dict): + version = data.get("version") or "unknown" + packages.append({ + "name": name, + "version": version, + "type": "npm-package", + "scope": "development" if data.get("dev") else "runtime", + }) + except Exception as e: + # Fallback to parsing package.json direct dependencies + try: + with open(package_json_path) as f: + pdata = json.load(f) + for name, ver in pdata.get("dependencies", {}).items(): + packages.append({ + "name": name, + "version": ver.replace("^", "").replace("~", ""), + "type": "npm-package", + "scope": "runtime", + }) + for name, ver in pdata.get("devDependencies", {}).items(): + packages.append({ + "name": name, + "version": ver.replace("^", "").replace("~", ""), + "type": "npm-package", + "scope": "development", + }) + except Exception: + pass + + return packages + +def generate_sbom(output_file: str, include_dev: bool = False) -> None: + """Generate SBOM in CycloneDX 1.4 format""" + + # Collect packages + packages: List[Dict[str, Any]] = [] + + # Add Python packages + try: + packages.extend(get_python_packages("backend/requirements.txt")) + if include_dev: + packages.extend(get_python_packages("backend/requirements-dev.txt")) + except Exception as e: + print(f"Warning: Failed to extract Python packages: {e}", file=sys.stderr) + + # Add npm packages + try: + packages.extend(get_npm_packages("frontend/package.json")) + except Exception as e: + print(f"Warning: Failed to extract npm packages: {e}", file=sys.stderr) + + # Build CycloneDX SBOM + sbom = { + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": f"urn:uuid:secuscan-{datetime.now().strftime('%Y%m%d%H%M%S')}", + "version": 1, + "metadata": { + "timestamp": datetime.now().isoformat(), + "component": { + "bom-ref": "secuscan", + "type": "application", + "name": "SecuScan", + "description": "Integrated security scanning platform", + }, + }, + "components": [ + { + "bom-ref": f"{pkg['name']}-{pkg['version']}", + "type": pkg["type"].split("-")[0], + "name": pkg["name"], + "version": pkg["version"], + "scope": pkg.get("scope", "required"), + } + for pkg in packages + ], + } + + # Ensure parent directory exists + Path(output_file).parent.mkdir(parents=True, exist_ok=True) + + with open(output_file, 'w') as f: + json.dump(sbom, f, indent=2) + + print(f"[OK] SBOM written to {output_file}") + print(f" Total components: {len(sbom['components'])}") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Generate Software Bill of Materials") + parser.add_argument("--output", default="sbom.json", help="Output file") + parser.add_argument("--include-dev", action="store_true", help="Include dev dependencies") + + args = parser.parse_args() + generate_sbom(args.output, include_dev=args.include_dev) diff --git a/scripts/refresh_plugin_checksum.py b/scripts/refresh_plugin_checksum.py index 7738589e8..2398a5d00 100644 --- a/scripts/refresh_plugin_checksum.py +++ b/scripts/refresh_plugin_checksum.py @@ -49,11 +49,11 @@ def compute_plugin_digest(metadata_file: Path, parser_file: Path) -> str: metadata_digest = hashlib.sha256(metadata_canonical.encode("utf-8")).hexdigest() # Hash parser.py if it exists, otherwise use empty string - parser_digest = ( - hashlib.sha256(parser_file.read_bytes()).hexdigest() - if parser_file.exists() - else "" - ) + parser_digest = "" + if parser_file.exists(): + parser_bytes = parser_file.read_bytes() + parser_bytes_normalized = parser_bytes.replace(b"\r\n", b"\n") + parser_digest = hashlib.sha256(parser_bytes_normalized).hexdigest() # Final digest combines both return hashlib.sha256( diff --git a/scripts/run_benchmarks.py b/scripts/run_benchmarks.py index 111c5d88b..077a51b98 100755 --- a/scripts/run_benchmarks.py +++ b/scripts/run_benchmarks.py @@ -6,7 +6,6 @@ """ import json -import os import subprocess import sys from pathlib import Path @@ -24,6 +23,7 @@ def main(): root_dir / "testing" / "backend" / "benchmarks" / "thresholds.json" ) results_path = root_dir / "benchmark_results.json" + comparison_path = root_dir / "benchmark_threshold_comparison.json" # 1. Load thresholds if not thresholds_path.exists(): @@ -56,6 +56,9 @@ def main(): # Run the tests. We capture output/errors normally. result = subprocess.run(cmd, cwd=str(root_dir)) + if result.returncode != 0 and not results_path.exists(): + sys.exit(result.returncode) + # 3. Read results if not results_path.exists(): print(f"\n{RED}Error: Benchmark run did not produce {results_path}{RESET}") @@ -72,9 +75,18 @@ def main(): print("-" * 82) has_regression = False + comparison_report = [] for metric, threshold in thresholds.items(): if metric not in results: print(f"{metric:<45} | {'N/A':<12} | {threshold:<12} | {RED}MISSING{RESET}") + comparison_report.append( + { + "metric": metric, + "measured": None, + "threshold": threshold, + "status": "MISSING", + } + ) has_regression = True continue @@ -98,9 +110,27 @@ def main(): has_regression = True print(f"{metric:<45} | {val_fmt:<12} | {thresh_fmt:<12} | {status_str:<6}") + comparison_report.append( + { + "metric": metric, + "measured": value, + "threshold": threshold, + "status": "PASS" if passed else "FAIL", + } + ) print("\n" + "=" * 82 + "\n") + with open(comparison_path, "w") as f: + json.dump( + { + "benchmarks": comparison_report, + "regression_detected": has_regression, + }, + f, + indent=2, + ) + print(f"Benchmark comparison saved to {comparison_path}") if has_regression: print( f"{RED}{BOLD}Performance regression detected! One or more metrics exceeded thresholds.{RESET}" diff --git a/scripts/validate_doc_anchors.py b/scripts/validate_doc_anchors.py new file mode 100644 index 000000000..0110163bf --- /dev/null +++ b/scripts/validate_doc_anchors.py @@ -0,0 +1,58 @@ +import pathlib +import re +import sys + +HEADING_RE = re.compile(r"^#+\s+(.+)$") +LINK_RE = re.compile(r"\]\(([^)]+)\)") + +def slugify(text): + text = text.lower().strip() + text = re.sub(r"[^\w\s-]", "", text) + text = re.sub(r"\s+", "-", text) + return text + + +def collect_anchors(md_file): + anchors = set() + + for line in md_file.read_text(encoding="utf-8").splitlines(): + match = HEADING_RE.match(line) + if match: + anchors.add(slugify(match.group(1))) + + return anchors + +def validate_file(md_file): + content = md_file.read_text(encoding="utf-8") + + anchors = collect_anchors(md_file) + + failures = [] + + for target in LINK_RE.findall(content): + if not target.startswith("#"): + continue + + anchor = target[1:] + + if anchor not in anchors: + failures.append(anchor) + + return failures + + +def main(): + failed = False + + for md_file in pathlib.Path("docs").rglob("*.md"): + missing = validate_file(md_file) + + for anchor in missing: + print(f"ERROR: {md_file} -> missing anchor #{anchor}") + failed = True + + sys.exit(1 if failed else 0) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate_issue_template_labels.py b/scripts/validate_issue_template_labels.py new file mode 100644 index 000000000..39e8a85f5 --- /dev/null +++ b/scripts/validate_issue_template_labels.py @@ -0,0 +1,101 @@ +from pathlib import Path +import re +import sys + +VALID_LABELS = { + "type:bug", + "type:feature", + "type:docs", + "type:devops", + "type:security", + "type:testing", + "type:performance", + "type:refactor", + "area:ci", + "area:docs", + "area:backend", + "area:frontend", + "priority:low", + "priority:medium", + "priority:high", + "level:beginner", + "level:intermediate", + "level:advanced", +} + +REPO_ROOT = Path(__file__).resolve().parents[1] +TEMPLATE_DIR = REPO_ROOT / ".github" / "ISSUE_TEMPLATE" + +errors = [] + + +def extract_front_matter(content): + if not content.startswith("---"): + return "" + + parts = content.split("---", 2) + if len(parts) < 3: + return "" + + return parts[1] + + +def parse_labels(raw_value): + raw_value = raw_value.strip().strip("\"'") + + if raw_value.startswith("[") and raw_value.endswith("]"): + raw_value = raw_value[1:-1] + + return [ + label.strip().strip("\"'") + for label in raw_value.split(",") + if label.strip() + ] + + +def extract_labels_from_front_matter(front_matter): + labels = [] + lines = front_matter.splitlines() + + for index, line in enumerate(lines): + match = re.match(r"^labels:\s*(.*)$", line) + + if not match: + continue + + value = match.group(1).strip() + + if value: + labels.extend(parse_labels(value)) + continue + + for next_line in lines[index + 1:]: + stripped = next_line.strip() + + if not stripped: + continue + + if not stripped.startswith("-"): + break + + labels.append(stripped[1:].strip().strip("\"'")) + + return labels + + +for template in list(TEMPLATE_DIR.glob("*.md")) + list(TEMPLATE_DIR.glob("*.yml")) + list(TEMPLATE_DIR.glob("*.yaml")): + content = template.read_text(encoding="utf-8") + front_matter = extract_front_matter(content) + labels = extract_labels_from_front_matter(front_matter) + + for label in labels: + if label not in VALID_LABELS: + errors.append("{}: invalid label '{}'".format(template.relative_to(REPO_ROOT), label)) + +if errors: + print("Invalid issue template labels found:") + for error in errors: + print("- {}".format(error)) + sys.exit(1) + +print("All issue template labels are valid.") \ No newline at end of file diff --git a/scripts/validate_plugins_catalog.py b/scripts/validate_plugins_catalog.py new file mode 100644 index 000000000..0b870d56d --- /dev/null +++ b/scripts/validate_plugins_catalog.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +""" +validate_plugins_catalog.py + +Automated sync check between PLUGINS.md catalog and actual plugin metadata files. + +This script validates that: + 1. All plugins in plugins/ have an entry in PLUGINS.md + 2. All entries in PLUGINS.md correspond to actual plugins + 3. Plugin counts match (safe, intrusive, exploit) + 4. Category counts are accurate + +Usage: + # Validate the catalog locally + python scripts/validate_plugins_catalog.py + + # Use custom paths (useful for CI) + python scripts/validate_plugins_catalog.py --catalog PLUGINS.md --plugins-dir plugins + +Exit codes: + 0 - Catalog is in sync + 1 - Validation failed (drift detected) +""" + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Dict, List, Tuple, Set + + +def parse_plugins_md(catalog_path: Path) -> Dict[str, Dict[str, str]]: + """ + Parse PLUGINS.md and extract plugin metadata from the index table. + + Returns dict mapping plugin ID -> {"name": ..., "category": ..., "safety": ...} + """ + if not catalog_path.exists(): + print(f"[ERROR] PLUGINS.md not found at: {catalog_path}", file=sys.stderr) + sys.exit(1) + + catalog_text = catalog_path.read_text(encoding="utf-8") + plugins: Dict[str, Dict[str, str]] = {} + + # Extract plugin index table using regex + # Pattern: | Plugin Name | `plugin_id` | `category` | `safety` | ... | + table_pattern = r"\|\s*([^\|]+?)\s*\|\s*`([^`]+?)`\s*\|\s*`([^`]+?)`\s*\|\s*`([^`]+?)`" + + for match in re.finditer(table_pattern, catalog_text): + name = match.group(1).strip() + plugin_id = match.group(2).strip() + category = match.group(3).strip() + safety = match.group(4).strip() + + # Skip header row + if plugin_id == "ID" or name == "Plugin": + continue + + plugins[plugin_id] = { + "name": name, + "category": category, + "safety": safety, + } + + return plugins + + +def scan_actual_plugins(plugins_dir: Path) -> Dict[str, Dict[str, str]]: + """ + Scan plugins/ directory and extract plugin metadata from each metadata.json. + + Returns dict mapping plugin ID -> {"id": ..., "category": ..., "safety": ...} + """ + if not plugins_dir.exists(): + print(f"[ERROR] plugins directory not found at: {plugins_dir}", file=sys.stderr) + sys.exit(1) + + plugins: Dict[str, Dict[str, str]] = {} + + for plugin_folder in sorted(plugins_dir.iterdir()): + if not plugin_folder.is_dir(): + continue + + metadata_file = plugin_folder / "metadata.json" + if not metadata_file.exists(): + print( + f"[WARNING] No metadata.json in {plugin_folder.name}", + file=sys.stderr + ) + continue + + try: + metadata = json.loads(metadata_file.read_text(encoding="utf-8")) + plugin_id = metadata.get("id", plugin_folder.name) + category = metadata.get("category", "unknown") + + # FIXED: Correctly parse safety level inner object from metadata.json + safety_block = metadata.get("safety", {}) + safety = safety_block.get("level", "unknown") if isinstance(safety_block, dict) else "unknown" + + plugins[plugin_id] = { + "id": plugin_id, + "category": category, + "safety": safety, + "folder": plugin_folder.name, + } + except json.JSONDecodeError as e: + print( + f"[WARNING] Invalid JSON in {plugin_folder.name}/metadata.json: {e}", + file=sys.stderr + ) + continue + + return plugins + + +def extract_counts_from_catalog(catalog_path: Path) -> Dict[str, int]: + """Extract At a Glance counts from PLUGINS.md.""" + counts: Dict[str, int] = {} + catalog_text = catalog_path.read_text(encoding="utf-8") + + # Pattern: "- Total plugins: 60" + pattern = r"-\s+(\w+[\w\s]*?):\s+(\d+)" + for match in re.finditer(pattern, catalog_text): + key = match.group(1).strip().lower().replace(" ", "_") + value = int(match.group(2)) + counts[key] = value + + return counts + + +def extract_category_counts_from_catalog(catalog_path: Path) -> Dict[str, int]: + """Extract category counts table from PLUGINS.md.""" + counts: Dict[str, int] = {} + catalog_text = catalog_path.read_text(encoding="utf-8") + + # Pattern: | `category_name` | count | + pattern = r"\|\s*`([^`]+?)`\s*\|\s*(\d+)\s*\|" + in_category_section = False + + for line in catalog_text.split("\n"): + if "Category Summary" in line: + in_category_section = True + continue + if in_category_section and "##" in line: + break + + if in_category_section: + match = re.search(pattern, line) + if match: + category = match.group(1).strip() + count = int(match.group(2)) + if category != "Category": # Skip header + counts[category] = count + + return counts + + +def validate_catalog(catalog_path: Path, plugins_dir: Path) -> Tuple[bool, List[str]]: + """ + Validate that PLUGINS.md is in sync with actual plugins. + + Returns (is_valid, list_of_issues) + """ + issues: List[str] = [] + + # Parse catalog and scan actual plugins + catalog_plugins = parse_plugins_md(catalog_path) + actual_plugins = scan_actual_plugins(plugins_dir) + + # Check 1: All actual plugins are in catalog + missing_in_catalog = set(actual_plugins.keys()) - set(catalog_plugins.keys()) + if missing_in_catalog: + issues.append( + f"Missing from PLUGINS.md: {', '.join(sorted(missing_in_catalog))}" + ) + + # Check 2: All catalog entries correspond to actual plugins + extra_in_catalog = set(catalog_plugins.keys()) - set(actual_plugins.keys()) + if extra_in_catalog: + issues.append( + f"In PLUGINS.md but not in plugins/: {', '.join(sorted(extra_in_catalog))}" + ) + + # Check 3: Total plugin count + catalog_counts = extract_counts_from_catalog(catalog_path) + total_plugins_expected = catalog_counts.get("total_plugins") + if total_plugins_expected is not None: + actual_total = len(actual_plugins) + if actual_total != total_plugins_expected: + issues.append( + f"Total plugins mismatch: catalog says {total_plugins_expected}, " + f"but found {actual_total}" + ) + + # Check 4: Safety level counts + actual_safety_counts = {} + for plugin in actual_plugins.values(): + safety = plugin.get("safety", "unknown") + actual_safety_counts[safety] = actual_safety_counts.get(safety, 0) + 1 + + for safety_level, count_key in [ + ("safe", "safe_plugins"), + ("intrusive", "intrusive_plugins"), + ("exploit", "exploit_plugins"), + ]: + expected = catalog_counts.get(count_key) + actual = actual_safety_counts.get(safety_level, 0) + if expected is not None and actual != expected: + issues.append( + f"Safety level '{safety_level}' count mismatch: " + f"catalog says {expected}, but found {actual}" + ) + + # Check 5: Category counts + catalog_category_counts = extract_category_counts_from_catalog(catalog_path) + actual_category_counts = {} + for plugin in actual_plugins.values(): + category = plugin.get("category", "unknown") + actual_category_counts[category] = actual_category_counts.get(category, 0) + 1 + + for category, expected_count in catalog_category_counts.items(): + actual_count = actual_category_counts.get(category, 0) + if actual_count != expected_count: + issues.append( + f"Category '{category}' count mismatch: " + f"catalog says {expected_count}, but found {actual_count}" + ) + + return (len(issues) == 0, issues) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Validate that PLUGINS.md is in sync with plugin metadata files.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python scripts/validate_plugins_catalog.py + python scripts/validate_plugins_catalog.py --catalog ./PLUGINS.md --plugins-dir ./plugins + """, + ) + + parser.add_argument( + "--catalog", + type=Path, + default=Path("PLUGINS.md"), + help="Path to PLUGINS.md (default: PLUGINS.md)", + ) + parser.add_argument( + "--plugins-dir", + type=Path, + default=Path("plugins"), + help="Path to plugins directory (default: plugins)", + ) + + args = parser.parse_args() + + # Ensure paths are absolute for consistent error messages + catalog_path = args.catalog.resolve() + plugins_path = args.plugins_dir.resolve() + + print(f"Validating plugin catalog...") + print(f" Catalog: {catalog_path}") + print(f" Plugins: {plugins_path}") + print() + + is_valid, issues = validate_catalog(catalog_path, plugins_path) + + if is_valid: + print("[✓] Catalog is in sync!") + sys.exit(0) + else: + print("[✗] Catalog validation failed:") + print() + print(f"DEBUG - Actual plugins found in folder: {sorted(list(scan_actual_plugins(plugins_path).keys()))}\n") + for issue in issues: + print(f" • {issue}") + print() + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/start.sh b/start.sh index 77ddae057..a9e52c222 100755 --- a/start.sh +++ b/start.sh @@ -23,6 +23,8 @@ echo " ╚═══════════════════════ echo "" # Pre-flight checks: kill existing servers on 8000 and 5173 +# If ports remain occupied after startup fails, see README.md +# Troubleshooting → Local Startup Troubleshooting. echo "🧹 Cleaning up existing processes on port 8000 and 5173..." lsof -ti :8000 | xargs kill -9 2>/dev/null || true lsof -ti :5173 | xargs kill -9 2>/dev/null || true @@ -32,6 +34,17 @@ sleep 1 echo "⚙ Setting up backend..." cd "$ROOT_DIR" +# Validate project structure before any expensive setup +if [ ! -f "$ROOT_DIR/backend/requirements.txt" ]; then + echo "ERROR: backend/requirements.txt not found." + exit 1 +fi + +if [ ! -d "$ROOT_DIR/frontend" ]; then + echo "ERROR: frontend directory not found." + exit 1 +fi + if [ -d "venv" ]; then source venv/bin/activate else diff --git a/testing/backend/benchmarks/bench_request_middleware.py b/testing/backend/benchmarks/bench_request_middleware.py new file mode 100644 index 000000000..714d20216 --- /dev/null +++ b/testing/backend/benchmarks/bench_request_middleware.py @@ -0,0 +1,85 @@ +import time +import pytest +from starlette.requests import Request +from starlette.responses import Response + +from backend.secuscan.request_middleware import RequestIDMiddleware + + +async def call_next(request): + return Response("ok") + + +def make_request(headers=None): + return Request( + { + "type": "http", + "method": "GET", + "path": "/", + "headers": headers or [], + } + ) + + +@pytest.mark.benchmark +@pytest.mark.asyncio +async def test_request_middleware_existing_id(record_benchmark): + middleware = RequestIDMiddleware(app=None) + + request = make_request( + [(b"x-request-id", b"test-request-id")] + ) + + latencies = [] + + for _ in range(100): + start = time.perf_counter() + + await middleware.dispatch(request, call_next) + + latencies.append( + (time.perf_counter() - start) * 1000.0 + ) + + mean_ms = sum(latencies) / len(latencies) + + record_benchmark( + "request_middleware_existing_id_mean_ms", + mean_ms, + ) + + print( + f"\n[bench_request_middleware_existing_id] " + f"Mean: {mean_ms:.4f}ms " + ) + + +@pytest.mark.benchmark +@pytest.mark.asyncio +async def test_request_middleware_uuid_generation(record_benchmark): + middleware = RequestIDMiddleware(app=None) + + latencies = [] + + for _ in range(100): + request = make_request() + + start = time.perf_counter() + + await middleware.dispatch(request, call_next) + + latencies.append( + (time.perf_counter() - start) * 1000.0 + ) + + mean_ms = sum(latencies) / len(latencies) + + record_benchmark( + "request_middleware_uuid_generation_mean_ms", + mean_ms, + ) + + print( + f"\n[bench_request_middleware_uuid_generation] " + f"Mean: {mean_ms:.4f}ms " + ) diff --git a/testing/backend/conftest.py b/testing/backend/conftest.py index e7d30d235..fc34fdb28 100644 --- a/testing/backend/conftest.py +++ b/testing/backend/conftest.py @@ -35,6 +35,11 @@ def setup_test_environment(monkeypatch): monkeypatch.setattr(settings, "plugins_dir", str(repo_root / "plugins")) monkeypatch.setattr(settings, "database_path", f"{temp_path}/test_secuscan.db") monkeypatch.setattr(settings, "vault_key", "test-vault-key-for-unit-tests-only") + monkeypatch.setattr(settings, "admin_api_key", "test-admin-key") + # Disable network policy enforcement in tests: integration tests mock + # _execute_command but the policy check runs before that mock fires. + # Tests that specifically test policy behaviour override this themselves. + monkeypatch.setattr(settings, "enforce_network_policy", False) settings.ensure_directories() @@ -48,6 +53,7 @@ def anyio_backend(): return "asyncio" + @pytest.fixture def test_client(setup_test_environment): """Provides a synchronous test client backed by initialized async services.""" diff --git a/testing/backend/integration/test_dependency_audit.py b/testing/backend/integration/test_dependency_audit.py new file mode 100644 index 000000000..666964a39 --- /dev/null +++ b/testing/backend/integration/test_dependency_audit.py @@ -0,0 +1,409 @@ +import pytest +import json +import tempfile +from pathlib import Path +import subprocess +import sys +import yaml +from datetime import datetime, timedelta + +SCRIPTS_DIR = Path(__file__).resolve().parents[3] / "scripts" + +class TestDependencyAudit: + """Test dependency audit and policy scripts""" + + def test_check_pip_audit_passes_with_no_vulns(self): + """Test check_pip_audit.py with a clean report""" + report = { + "vulnerabilities": [], + "stats": {"total": 0, "critical": 0, "high": 0, "medium": 0, "low": 0}, + } + + config = { + "policy": {"min_severity_to_block": "high"}, + "exceptions": {}, + } + + with tempfile.TemporaryDirectory() as tmpdir: + report_file = Path(tmpdir) / "report.json" + config_file = Path(tmpdir) / "config.yaml" + + with open(report_file, 'w') as f: + json.dump(report, f) + + with open(config_file, 'w') as f: + yaml.dump(config, f) + + result = subprocess.run( + [ + sys.executable, str(SCRIPTS_DIR / "check_pip_audit.py"), + "--report", str(report_file), + "--config", str(config_file), + "--min-severity", "high", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert "no vulnerabilities" in result.stderr.lower() or "passed" in result.stderr.lower() + + def test_check_pip_audit_fails_with_high_severity(self): + """Test check_pip_audit.py blocks on high-severity vulnerability""" + report = { + "vulnerabilities": [ + { + "package": "bad-package", + "installed_version": "1.0.0", + "cve": "CVE-2026-12345", + "vulnerability": { + "severity": "high", + "advisory": "Critical RCE vulnerability", + }, + } + ], + } + + config = { + "policy": {"min_severity_to_block": "high"}, + "exceptions": {}, + } + + with tempfile.TemporaryDirectory() as tmpdir: + report_file = Path(tmpdir) / "report.json" + config_file = Path(tmpdir) / "config.yaml" + + with open(report_file, 'w') as f: + json.dump(report, f) + + with open(config_file, 'w') as f: + yaml.dump(config, f) + + result = subprocess.run( + [ + sys.executable, str(SCRIPTS_DIR / "check_pip_audit.py"), + "--report", str(report_file), + "--config", str(config_file), + "--min-severity", "high", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "CVE-2026-12345" in result.stderr + + def test_pip_exception_bypasses_block(self): + """Test that documented pip exceptions allow CI to pass""" + future_date = (datetime.now() + timedelta(days=30)).date().isoformat() + + report = { + "vulnerabilities": [ + { + "package": "accepted-risk", + "installed_version": "1.0.0", + "cve": "CVE-2026-11111", + "vulnerability": { + "severity": "high", + "advisory": "Requires X condition", + }, + } + ], + } + + config = { + "policy": {"min_severity_to_block": "high"}, + "exceptions": { + "CVE-2026-11111": { + "package": "accepted-risk", + "severity": "high", + "reason": "Risk accepted; condition doesn't apply", + "expires_at": future_date, + "approved_by": "security-team", + } + }, + } + + with tempfile.TemporaryDirectory() as tmpdir: + report_file = Path(tmpdir) / "report.json" + config_file = Path(tmpdir) / "config.yaml" + + with open(report_file, 'w') as f: + json.dump(report, f) + + with open(config_file, 'w') as f: + yaml.dump(config, f) + + result = subprocess.run( + [ + sys.executable, str(SCRIPTS_DIR / "check_pip_audit.py"), + "--report", str(report_file), + "--config", str(config_file), + "--min-severity", "high", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert "exceptions applied" in result.stderr.lower() or "exceptions applied" in result.stdout.lower() + + def test_pip_expired_exception_blocks(self): + """Test that expired exceptions are treated as violations""" + past_date = (datetime.now() - timedelta(days=3)).date().isoformat() + + report = { + "vulnerabilities": [ + { + "package": "expired-exception-pkg", + "installed_version": "1.0.0", + "cve": "CVE-2026-22222", + "vulnerability": { + "severity": "high", + "advisory": "Something bad", + }, + } + ], + } + + config = { + "policy": {"min_severity_to_block": "high"}, + "exceptions": { + "CVE-2026-22222": { + "package": "expired-exception-pkg", + "severity": "high", + "reason": "Old exception", + "expires_at": past_date, + "approved_by": "security-team", + } + }, + } + + with tempfile.TemporaryDirectory() as tmpdir: + report_file = Path(tmpdir) / "report.json" + config_file = Path(tmpdir) / "config.yaml" + + with open(report_file, 'w') as f: + json.dump(report, f) + + with open(config_file, 'w') as f: + yaml.dump(config, f) + + result = subprocess.run( + [ + sys.executable, str(SCRIPTS_DIR / "check_pip_audit.py"), + "--report", str(report_file), + "--config", str(config_file), + "--min-severity", "high", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "expired" in result.stderr.lower() + + def test_check_npm_audit_passes_with_no_vulns(self): + """Test check_npm_audit.py with a clean npm report""" + report = { + "vulnerabilities": {}, + "metadata": {"vulnerabilities": {"total": 0}}, + } + + config = { + "policy": {"min_severity_to_block": "high"}, + "exceptions": {}, + } + + with tempfile.TemporaryDirectory() as tmpdir: + report_file = Path(tmpdir) / "report.json" + config_file = Path(tmpdir) / "config.yaml" + + with open(report_file, 'w') as f: + json.dump(report, f) + + with open(config_file, 'w') as f: + yaml.dump(config, f) + + result = subprocess.run( + [ + sys.executable, str(SCRIPTS_DIR / "check_npm_audit.py"), + "--report", str(report_file), + "--config", str(config_file), + "--min-severity", "high", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert "no npm vulnerabilities" in result.stderr.lower() or "passed" in result.stderr.lower() + + def test_check_npm_audit_fails_with_high_severity(self): + """Test check_npm_audit.py blocks on high-severity vulnerability""" + report = { + "vulnerabilities": { + "framer-motion": { + "name": "framer-motion", + "severity": "high", + "via": [ + { + "source": "GHSA-jqrj-82ww", + "name": "framer-motion", + "url": "https://github.com/advisories/GHSA-jqrj-82ww", + "severity": "high", + "cwe": ["CWE-94"], + } + ], + } + } + } + + config = { + "policy": {"min_severity_to_block": "high"}, + "exceptions": {}, + } + + with tempfile.TemporaryDirectory() as tmpdir: + report_file = Path(tmpdir) / "report.json" + config_file = Path(tmpdir) / "config.yaml" + + with open(report_file, 'w') as f: + json.dump(report, f) + + with open(config_file, 'w') as f: + yaml.dump(config, f) + + result = subprocess.run( + [ + sys.executable, str(SCRIPTS_DIR / "check_npm_audit.py"), + "--report", str(report_file), + "--config", str(config_file), + "--min-severity", "high", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert "GHSA-jqrj-82ww" in result.stderr + + def test_npm_exception_bypasses_block(self): + """Test that npm exceptions bypass blocker""" + future_date = (datetime.now() + timedelta(days=30)).date().isoformat() + + report = { + "vulnerabilities": { + "framer-motion": { + "name": "framer-motion", + "severity": "high", + "via": [ + { + "source": "GHSA-jqrj-82ww", + "name": "framer-motion", + "url": "https://github.com/advisories/GHSA-jqrj-82ww", + "severity": "high", + "cwe": ["CWE-94"], + } + ], + } + } + } + + config = { + "policy": {"min_severity_to_block": "high"}, + "exceptions": { + "GHSA-jqrj-82ww": { + "package": "framer-motion", + "severity": "high", + "reason": "Excepted RCE vulnerability", + "expires_at": future_date, + } + }, + } + + with tempfile.TemporaryDirectory() as tmpdir: + report_file = Path(tmpdir) / "report.json" + config_file = Path(tmpdir) / "config.yaml" + + with open(report_file, 'w') as f: + json.dump(report, f) + + with open(config_file, 'w') as f: + yaml.dump(config, f) + + result = subprocess.run( + [ + sys.executable, str(SCRIPTS_DIR / "check_npm_audit.py"), + "--report", str(report_file), + "--config", str(config_file), + "--min-severity", "high", + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert "excepted" in result.stderr.lower() or "excepted" in result.stdout.lower() + + def test_generate_sbom(self): + """Test generate_sbom.py generates CycloneDX 1.4 formats""" + with tempfile.TemporaryDirectory() as tmpdir: + sbom_file = Path(tmpdir) / "sbom.json" + + result = subprocess.run( + [ + sys.executable, str(SCRIPTS_DIR / "generate_sbom.py"), + "--output", str(sbom_file), + ], + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert sbom_file.exists() + + with open(sbom_file) as f: + sbom = json.load(f) + + assert sbom.get("bomFormat") == "CycloneDX" + assert sbom.get("specVersion") == "1.4" + assert "components" in sbom + + def test_missing_report_returns_exit_code_1(self): + """Verify check_pip_audit.py and check_npm_audit.py return exit code 1 when the report doesn't exist""" + with tempfile.TemporaryDirectory() as tmpdir: + missing_report = Path(tmpdir) / "does_not_exist.json" + config_file = Path(tmpdir) / "config.yaml" + config = { + "policy": {"min_severity_to_block": "high"}, + "exceptions": {}, + } + with open(config_file, 'w') as f: + yaml.dump(config, f) + + # Test check_pip_audit.py + result_pip = subprocess.run( + [ + sys.executable, str(SCRIPTS_DIR / "check_pip_audit.py"), + "--report", str(missing_report), + "--config", str(config_file), + ], + capture_output=True, + text=True, + ) + assert result_pip.returncode == 1 + assert "is missing or empty" in result_pip.stderr + + # Test check_npm_audit.py + result_npm = subprocess.run( + [ + sys.executable, str(SCRIPTS_DIR / "check_npm_audit.py"), + "--report", str(missing_report), + "--config", str(config_file), + ], + capture_output=True, + text=True, + ) + assert result_npm.returncode == 1 + assert "is missing or empty" in result_npm.stderr diff --git a/testing/backend/integration/test_docker_hardening.py b/testing/backend/integration/test_docker_hardening.py index fe82b3912..f5634146d 100644 --- a/testing/backend/integration/test_docker_hardening.py +++ b/testing/backend/integration/test_docker_hardening.py @@ -11,7 +11,6 @@ pytest testing/backend/test_docker_hardening.py -v """ - import json import subprocess import shutil @@ -43,8 +42,11 @@ def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: def _docker_available() -> bool: - result = _run(["docker", "info"]) - return result.returncode == 0 + try: + result = _run(["docker", "info"]) + return result.returncode == 0 + except (FileNotFoundError, subprocess.SubprocessError): + return False def _trivy_available() -> bool: @@ -64,15 +66,16 @@ def _build_image(service: str) -> str: info = IMAGES[service] result = _run( [ - "docker", "build", - "-t", info["tag"], - "-f", info["dockerfile"], + "docker", + "build", + "-t", + info["tag"], + "-f", + info["dockerfile"], info["context"], ] ) - assert result.returncode == 0, ( - f"Failed to build {service} image:\n{result.stderr}" - ) + assert result.returncode == 0, f"Failed to build {service} image:\n{result.stderr}" return info["tag"] @@ -84,17 +87,32 @@ def _container_uid(tag: str) -> int: def _container_user(tag: str) -> str: result = _run(["docker", "run", "--rm", tag, "whoami"]) - assert result.returncode == 0, f"Could not get username from container: {result.stderr}" + assert result.returncode == 0, ( + f"Could not get username from container: {result.stderr}" + ) return result.stdout.strip() def _suid_files(tag: str) -> list[str]: result = _run( [ - "docker", "run", "--rm", "--entrypoint", "find", + "docker", + "run", + "--rm", + "--entrypoint", + "find", tag, - "/", "-xdev", "(", "-perm", "-4000", "-o", "-perm", "-2000", ")", - "-type", "f", + "/", + "-xdev", + "(", + "-perm", + "-4000", + "-o", + "-perm", + "-2000", + ")", + "-type", + "f", ] ) lines = [l for l in result.stdout.splitlines() if l.strip()] @@ -105,9 +123,12 @@ def _trivy_critical_count(tag: str) -> int: """Return number of CRITICAL CVEs found by Trivy.""" result = _run( [ - "trivy", "image", - "--format", "json", - "--severity", "CRITICAL", + "trivy", + "image", + "--format", + "json", + "--severity", + "CRITICAL", "--ignore-unfixed", "--quiet", tag, @@ -146,6 +167,7 @@ def frontend_image(): # Tests: non-root user + @requires_docker class TestNonRootUser: """The container must not run as root (UID 0).""" @@ -220,6 +242,7 @@ def test_frontend_no_unexpected_suid(self, frontend_image): # Tests: Dockerfile structural checks (static analysis) + class TestDockerfileStructure: """Parse Dockerfiles to confirm structural hardening without Docker.""" @@ -282,6 +305,7 @@ def test_backend_no_apt_cache_left_behind(self): content = self._read_dockerfile("backend") # Any RUN apt-get install line must be paired with cleanup in the same RUN import re + run_blocks = re.findall( r"RUN (.+?)(?=\nRUN |\nCOPY |\nUSER |\nFROM |\Z)", content, re.DOTALL ) @@ -294,6 +318,7 @@ def test_backend_no_apt_cache_left_behind(self): # Tests: Trivy CVE gate + @requires_docker @requires_trivy class TestTrivyCVEGate: @@ -325,9 +350,12 @@ def test_policy_gate_detects_vulnerable_image(self): result = subprocess.run( [ - "trivy", "image", - "--format", "json", - "--severity", "CRITICAL", + "trivy", + "image", + "--format", + "json", + "--severity", + "CRITICAL", "--quiet", vulnerable_tag, ], @@ -340,11 +368,10 @@ def test_policy_gate_detects_vulnerable_image(self): pytest.skip("Trivy returned non-JSON output for vulnerable image.") count = sum( - len(t.get("Vulnerabilities") or []) - for t in data.get("Results", []) + len(t.get("Vulnerabilities") or []) for t in data.get("Results", []) ) assert count > 0, ( "Expected to find CRITICAL CVEs in the known-vulnerable image " f"({vulnerable_tag}), but found none. " "Trivy may not be scanning correctly." - ) \ No newline at end of file + ) diff --git a/testing/backend/integration/test_execution_context_resources.py b/testing/backend/integration/test_execution_context_resources.py new file mode 100644 index 000000000..1a8878293 --- /dev/null +++ b/testing/backend/integration/test_execution_context_resources.py @@ -0,0 +1,140 @@ +import time +from unittest.mock import patch + +from backend.secuscan.models import TaskStatus + + +def _create_target_policy(test_client, **overrides): + payload = { + "name": "Authorized External Scope", + "description": "Allows approved public-target assessment", + "allow_public_targets": True, + "allow_exploit_validation": True, + "allow_authenticated_scan": True, + "default_validation_mode": "proof", + "allowed_targets": ["8.8.8.8/32", "https://example.com"], + } + payload.update(overrides) + response = test_client.post("/api/v1/target-policies", json=payload) + assert response.status_code == 200 + return response.json() + + +def test_target_policy_and_profile_resources(test_client): + policy = _create_target_policy(test_client) + listed_policies = test_client.get("/api/v1/target-policies") + assert listed_policies.status_code == 200 + assert any(item["id"] == policy["id"] for item in listed_policies.json()["items"]) + + credential = test_client.post( + "/api/v1/credential-profiles", + json={ + "name": "Basic Auth", + "username_secret_name": "scanner-user", + "password_secret_name": "scanner-pass", + "extra_headers": {"X-Role": "scanner"}, + }, + ) + assert credential.status_code == 200 + + session = test_client.post( + "/api/v1/session-profiles", + json={ + "name": "Captured Session", + "cookie_secret_name": "scanner-cookie", + "extra_headers": {"X-Session-Mode": "replay"}, + }, + ) + assert session.status_code == 200 + + assert test_client.get("/api/v1/credential-profiles").json()["total"] == 1 + assert test_client.get("/api/v1/session-profiles").json()["total"] == 1 + + +def test_public_target_allowed_when_target_policy_opted_in(test_client, monkeypatch): + from backend.secuscan.config import settings + + monkeypatch.setattr(settings, "safe_mode_default", True) + policy = _create_target_policy(test_client, allow_public_targets=True, allow_exploit_validation=False) + + with patch("backend.secuscan.executor.TaskExecutor._execute_command") as mock_exec: + mock_exec.return_value = ("Mocked successful output", 0) + response = test_client.post( + "/api/v1/task/start", + json={ + "plugin_id": "nmap", + "inputs": {"target": "8.8.8.8/32"}, + "consent_granted": True, + "execution_context": { + "target_policy_id": policy["id"], + "scan_profile": "standard", + "validation_mode": "detect_only", + "evidence_level": "standard", + }, + }, + ) + assert response.status_code == 200, response.text + + +def test_exploit_plugin_requires_exploit_enabled_target_policy(test_client): + response = test_client.post( + "/api/v1/task/start", + json={ + "plugin_id": "xss_exploiter", + "inputs": {"target": "https://example.com/search?q=test"}, + "consent_granted": True, + "execution_context": { + "scan_profile": "standard", + "validation_mode": "proof", + "evidence_level": "standard", + }, + }, + ) + assert response.status_code == 400 + assert "exploit validation" in response.json()["detail"].lower() + + +def test_network_scanner_correlates_service_to_cve(test_client, monkeypatch): + from backend.secuscan.config import settings + + monkeypatch.setattr(settings, "safe_mode_default", False) + nmap_output = "\n".join( + [ + "22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5", + "80/tcp open http nginx 1.18.0", + ] + ) + + with patch("backend.secuscan.scanners.base.BaseScanner._execute_command") as mock_exec: + mock_exec.return_value = (nmap_output, 0) + + response = test_client.post( + "/api/v1/task/start", + json={ + "plugin_id": "network_scanner", + "inputs": {"target": "192.168.1.10"}, + "consent_granted": True, + "execution_context": { + "scan_profile": "standard", + "validation_mode": "detect_only", + "evidence_level": "standard", + }, + }, + ) + assert response.status_code == 200 + task_id = response.json()["task_id"] + + for _ in range(10): + status = test_client.get(f"/api/v1/task/{task_id}/status").json()["status"] + if status == TaskStatus.COMPLETED.value: + break + time.sleep(0.1) + + result = test_client.get(f"/api/v1/task/{task_id}/result") + assert result.status_code == 200 + findings = result.json()["findings"] + assert any(f.get("cpe") == "cpe:/a:nginx:nginx:1.18.0" for f in findings) + assert any(f.get("cve") == "CVE-2021-23017" for f in findings) + + asset_rows = test_client.get("/api/v1/assets/services").json()["items"] + assert any(item.get("cpe") == "cpe:/a:nginx:nginx:1.18.0" for item in asset_rows) diff --git a/testing/backend/integration/test_owner_authorization.py b/testing/backend/integration/test_owner_authorization.py new file mode 100644 index 000000000..c68a8c009 --- /dev/null +++ b/testing/backend/integration/test_owner_authorization.py @@ -0,0 +1,260 @@ +""" +Integration tests for per-user / per-workspace ownership of tasks, findings, +and reports (issue #401 — Broken Object Level Authorization / BOLA). + +Two distinct users are simulated by sending different ``X-User-Id`` headers on +top of the shared deployment API key (see auth.resolve_owner_id). The tests +assert that User B can never read, list, delete, or export User A's data, while +User A retains full access to their own. +""" + +import sqlite3 +import time + +import pytest + +from backend.secuscan.config import settings + + +ALICE = {"X-User-Id": "alice"} +BOB = {"X-User-Id": "bob"} + +# owner_id values as persisted by auth.resolve_owner_id for the headers above. +ALICE_OWNER = "user:alice" +BOB_OWNER = "user:bob" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _seed_task(owner_id: str, task_id: str, *, status: str = "completed") -> None: + """Insert a task row directly with an explicit owner_id.""" + conn = sqlite3.connect(settings.database_path) + try: + conn.execute( + """ + INSERT INTO tasks (id, owner_id, plugin_id, tool_name, target, + status, inputs_json, structured_json, consent_granted) + VALUES (?, ?, 'nmap', 'nmap', '127.0.0.1', ?, '{}', '{"findings": []}', 1) + """, + (task_id, owner_id, status), + ) + conn.commit() + finally: + conn.close() + + +def _seed_finding(owner_id: str, finding_id: str, task_id: str) -> None: + conn = sqlite3.connect(settings.database_path) + try: + conn.execute( + """ + INSERT INTO findings (id, owner_id, task_id, plugin_id, title, category, + severity, target, description, remediation) + VALUES (?, ?, ?, 'nmap', 'Open port', 'network', 'low', '127.0.0.1', 'desc', 'fix') + """, + (finding_id, owner_id, task_id), + ) + conn.commit() + finally: + conn.close() + + +def _seed_report(owner_id: str, report_id: str, task_id: str) -> None: + conn = sqlite3.connect(settings.database_path) + try: + conn.execute( + """ + INSERT INTO reports (id, owner_id, task_id, name, type, status) + VALUES (?, ?, ?, 'report', 'technical', 'ready') + """, + (report_id, owner_id, task_id), + ) + conn.commit() + finally: + conn.close() + + +def _task_owner(task_id: str): + conn = sqlite3.connect(settings.database_path) + try: + cur = conn.execute("SELECT owner_id FROM tasks WHERE id = ?", (task_id,)) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Creation wiring +# --------------------------------------------------------------------------- + +def test_started_task_records_requesting_user_as_owner(test_client): + """A task created via the API is owned by the requesting user.""" + from unittest.mock import patch + + with patch("backend.secuscan.executor.TaskExecutor._execute_command") as mock_exec: + mock_exec.return_value = ("Mocked output", 0) + resp = test_client.post( + "/api/v1/task/start", + json={ + "plugin_id": "http_inspector", + "preset": "quick", + "inputs": {"url": "http://127.0.0.1:8000"}, + "consent_granted": True, + }, + headers=ALICE, + ) + assert resp.status_code == 200, resp.text + task_id = resp.json()["task_id"] + assert _task_owner(task_id) == ALICE_OWNER + + +def test_tasks_created_by_distinct_users_get_distinct_owners(test_client): + """The default (no header) owner is distinct from an explicit user.""" + _seed_task("default", "legacy-task") + _seed_task(ALICE_OWNER, "alice-task") + + # The default/no-header client sees only the legacy task. + resp = test_client.get("/api/v1/tasks") + assert resp.status_code == 200 + ids = {t["task_id"] for t in resp.json()["tasks"]} + assert "legacy-task" in ids + assert "alice-task" not in ids + + +# --------------------------------------------------------------------------- +# Cross-user GET / report / cancel / delete on a single task +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "method,path_tmpl", + [ + ("get", "/api/v1/task/{tid}/status"), + ("get", "/api/v1/task/{tid}/result"), + ("get", "/api/v1/task/{tid}/stream"), + ("get", "/api/v1/task/{tid}/report/csv"), + ("get", "/api/v1/task/{tid}/report/html"), + ("get", "/api/v1/task/{tid}/report/pdf"), + ("get", "/api/v1/task/{tid}/report/sarif"), + ("post", "/api/v1/task/{tid}/cancel"), + ("delete", "/api/v1/task/{tid}"), + ], +) +def test_user_b_cannot_access_user_a_task(test_client, method, path_tmpl): + """Every task-scoped endpoint returns 403 for a non-owner.""" + _seed_task(ALICE_OWNER, "alice-task") + path = path_tmpl.format(tid="alice-task") + + resp = getattr(test_client, method)(path, headers=BOB) + assert resp.status_code == 403, f"{method.upper()} {path} -> {resp.status_code}: {resp.text}" + + +def test_user_a_can_access_own_task(test_client): + """The owner retains full access to their own task.""" + _seed_task(ALICE_OWNER, "alice-task") + + assert test_client.get("/api/v1/task/alice-task/status", headers=ALICE).status_code == 200 + assert test_client.get("/api/v1/task/alice-task/result", headers=ALICE).status_code == 200 + + +def test_unknown_task_returns_404_not_403(test_client): + """A genuinely missing task is 404; only ownership mismatch is 403.""" + resp = test_client.get("/api/v1/task/does-not-exist/status", headers=BOB) + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Listing endpoints must not leak another user's resources +# --------------------------------------------------------------------------- + +def test_task_list_is_scoped_to_owner(test_client): + _seed_task(ALICE_OWNER, "alice-task") + _seed_task(BOB_OWNER, "bob-task") + + alice_ids = {t["task_id"] for t in test_client.get("/api/v1/tasks", headers=ALICE).json()["tasks"]} + bob_ids = {t["task_id"] for t in test_client.get("/api/v1/tasks", headers=BOB).json()["tasks"]} + + assert "alice-task" in alice_ids and "bob-task" not in alice_ids + assert "bob-task" in bob_ids and "alice-task" not in bob_ids + + +def test_findings_list_is_scoped_to_owner(test_client): + _seed_task(ALICE_OWNER, "alice-task") + _seed_task(BOB_OWNER, "bob-task") + _seed_finding(ALICE_OWNER, "alice-finding", "alice-task") + _seed_finding(BOB_OWNER, "bob-finding", "bob-task") + + alice_findings = {f["id"] for f in test_client.get("/api/v1/findings", headers=ALICE).json()["findings"]} + bob_findings = {f["id"] for f in test_client.get("/api/v1/findings", headers=BOB).json()["findings"]} + + assert alice_findings == {"alice-finding"} + assert bob_findings == {"bob-finding"} + + +def test_reports_list_is_scoped_to_owner(test_client): + _seed_task(ALICE_OWNER, "alice-task") + _seed_task(BOB_OWNER, "bob-task") + _seed_report(ALICE_OWNER, "report:alice", "alice-task") + _seed_report(BOB_OWNER, "report:bob", "bob-task") + + alice_reports = {r["id"] for r in test_client.get("/api/v1/reports", headers=ALICE).json()["reports"]} + bob_reports = {r["id"] for r in test_client.get("/api/v1/reports", headers=BOB).json()["reports"]} + + assert alice_reports == {"report:alice"} + assert bob_reports == {"report:bob"} + + +def test_finding_detail_blocks_cross_user_access(test_client): + _seed_task(ALICE_OWNER, "alice-task") + _seed_finding(ALICE_OWNER, "alice-finding", "alice-task") + + assert test_client.get("/api/v1/finding/alice-finding", headers=BOB).status_code == 403 + assert test_client.get("/api/v1/finding/alice-finding", headers=ALICE).status_code == 200 + + +# --------------------------------------------------------------------------- +# Bulk delete must only ever touch the caller's own tasks +# --------------------------------------------------------------------------- + +def test_bulk_delete_ignores_other_users_tasks(test_client): + _seed_task(ALICE_OWNER, "alice-task") + + resp = test_client.request("DELETE", "/api/v1/tasks/bulk", json=["alice-task"], headers=BOB) + assert resp.status_code == 200 + assert resp.json()["deleted_count"] == 0 + # Alice's task must still exist. + assert _task_owner("alice-task") == ALICE_OWNER + + +def test_bulk_delete_removes_only_owned_tasks(test_client): + _seed_task(ALICE_OWNER, "alice-task") + _seed_task(BOB_OWNER, "bob-task") + + # Alice attempts to delete both her task and Bob's in one request. + resp = test_client.request( + "DELETE", "/api/v1/tasks/bulk", json=["alice-task", "bob-task"], headers=ALICE + ) + assert resp.status_code == 200 + assert resp.json()["deleted_count"] == 1 + assert _task_owner("alice-task") is None + assert _task_owner("bob-task") == BOB_OWNER + + +def test_clear_only_purges_callers_history(test_client): + _seed_task(ALICE_OWNER, "alice-task") + _seed_task(BOB_OWNER, "bob-task") + + resp = test_client.delete("/api/v1/tasks/clear", headers=ALICE) + assert resp.status_code == 200 + assert _task_owner("alice-task") is None + assert _task_owner("bob-task") == BOB_OWNER + + +def test_owner_can_delete_own_task(test_client): + _seed_task(ALICE_OWNER, "alice-task", status="completed") + + resp = test_client.delete("/api/v1/task/alice-task", headers=ALICE) + assert resp.status_code == 200 + assert _task_owner("alice-task") is None diff --git a/testing/backend/integration/test_parser_output_contract.py b/testing/backend/integration/test_parser_output_contract.py index 1c6ce12bf..079895bed 100644 --- a/testing/backend/integration/test_parser_output_contract.py +++ b/testing/backend/integration/test_parser_output_contract.py @@ -6,6 +6,7 @@ import pytest import asyncio +from backend.secuscan.capabilities import effective_capabilities from backend.secuscan.config import settings from backend.secuscan.plugins import init_plugins from backend.secuscan.executor import executor @@ -15,6 +16,15 @@ # Dynamic Discovery of All Bundled Custom Parsers # --------------------------------------------------------------------------- +KNOWN_PARSER_CAPABILITY_GROUPS = frozenset({"network", "intrusive", "credentials", "exploit"}) +SELECTED_PARSER_CAPABILITY_GROUP = os.getenv("PARSER_CAPABILITY_GROUP", "").strip().lower() +if SELECTED_PARSER_CAPABILITY_GROUP and SELECTED_PARSER_CAPABILITY_GROUP not in KNOWN_PARSER_CAPABILITY_GROUPS: + raise RuntimeError( + f"Unsupported parser capability group '{SELECTED_PARSER_CAPABILITY_GROUP}'." + f"Valid groups: {sorted(KNOWN_PARSER_CAPABILITY_GROUPS)}" + ) + + def get_all_custom_parsers() -> list[tuple[str, Path, Path]]: """ Scans the plugins directory to discover all bundled scanners @@ -36,6 +46,57 @@ def get_all_custom_parsers() -> list[tuple[str, Path, Path]]: return sorted(parsers, key=lambda x: x[0]) + +def get_parser_capability_group(metadata_path: Path) -> str: + """Return the highest-consequence capability group for CI grouping.""" + data = json.loads(metadata_path.read_text(encoding="utf-8")) + caps = data.get("capabilities") + if caps is None: + safety_level = data.get("safety", {}).get("level", "safe") + caps = list(effective_capabilities(None, safety_level, data.get("id", "unknown"))) + + if "exploit" in caps: + return "exploit" + # if "docker" in caps: + # return "docker" + if "credentials" in caps: + return "credentials" + if "intrusive" in caps: + return "intrusive" + return "network" + + +def get_selected_custom_parsers() -> list[tuple[str, Path, Path]]: + """Return plugin parsers filtered by the requested capability group.""" + if not SELECTED_PARSER_CAPABILITY_GROUP: + return get_all_custom_parsers() + + selected = [ + item + for item in get_all_custom_parsers() + if get_parser_capability_group(item[2]) == SELECTED_PARSER_CAPABILITY_GROUP + ] + + if not selected: + raise RuntimeError( + f"No parser contracts found for capability group '{SELECTED_PARSER_CAPABILITY_GROUP}'. " + "Verify that the group name is correct and that plugin metadata declares the expected capabilities." + ) + + return selected + + +def test_parser_capability_groups_are_populated() -> None: + """Ensure known CI capability groups map to at least one parser plugin.""" + groups = {get_parser_capability_group(metadata_path) for _, _, metadata_path in get_all_custom_parsers()} + missing = KNOWN_PARSER_CAPABILITY_GROUPS - groups + assert not missing, ( + "The parser capability matrix contains groups with no matching plugins: " + f"{sorted(missing)}. " + "Update KNOWN_PARSER_CAPABILITY_GROUPS or plugin metadata capabilities accordingly." + ) + + # --------------------------------------------------------------------------- # Module Fixtures # --------------------------------------------------------------------------- @@ -50,7 +111,7 @@ def plugin_manager_instance(): # Parser Contract Tests # --------------------------------------------------------------------------- -@pytest.mark.parametrize("plugin_id, parser_path, metadata_path", get_all_custom_parsers()) +@pytest.mark.parametrize("plugin_id, parser_path, metadata_path", get_selected_custom_parsers()) def test_parser_contract_compliance(plugin_id, parser_path, metadata_path, plugin_manager_instance): """ Verifies that every custom parser in the codebase complies with diff --git a/testing/backend/integration/test_phase3_plugins.py b/testing/backend/integration/test_phase3_plugins.py index 1f0461a18..4e5c828f7 100644 --- a/testing/backend/integration/test_phase3_plugins.py +++ b/testing/backend/integration/test_phase3_plugins.py @@ -16,7 +16,23 @@ } -def run_plugin_test(test_client, plugin_id, inputs, mock_output): +def _create_target_policy(test_client, **overrides): + payload = { + "name": "Authorized Offensive Scope", + "description": "Allows approved exploit validation during tests.", + "allow_public_targets": True, + "allow_exploit_validation": True, + "allow_authenticated_scan": True, + "default_validation_mode": "proof", + "allowed_targets": ["10.0.0.10", "https://api.lab", "https://wp.lab", "https://joomla.lab", "https://drupal.lab"], + } + payload.update(overrides) + response = test_client.post("/api/v1/target-policies", json=payload) + assert response.status_code == 200, response.text + return response.json() + + +def run_plugin_test(test_client, plugin_id, inputs, mock_output, execution_context=None): """Helper to run a plugin test with mocked execution.""" with patch("backend.secuscan.executor.TaskExecutor._execute_command") as mock_exec: mock_exec.return_value = (mock_output, 0) @@ -26,6 +42,8 @@ def run_plugin_test(test_client, plugin_id, inputs, mock_output): "inputs": inputs, "consent_granted": True, } + if execution_context is not None: + payload["execution_context"] = execution_context response = test_client.post("/api/v1/task/start", json=payload) assert response.status_code == 200, f"Failed to start {plugin_id}: {response.text}" @@ -125,17 +143,25 @@ def test_volatility(test_client): def test_hashcat(test_client): + policy = _create_target_policy(test_client) mock_out = "5f4dcc3b5aa765d61d8327deb882cf99:password" result = run_plugin_test( test_client, "hashcat", {"target": "/tmp/hashes.txt", "hash_type": 0, "attack_mode": 0, "wordlist": "words.txt"}, mock_out, + execution_context={ + "target_policy_id": policy["id"], + "scan_profile": "standard", + "validation_mode": "proof", + "evidence_level": "standard", + }, ) assert any("Hash Recovered" in f["title"] for f in result["structured"]["findings"]) def test_metasploit(test_client): + policy = _create_target_policy(test_client) mock_out = "[*] Handler started\n[*] Meterpreter session 2 opened" result = run_plugin_test( test_client, @@ -146,12 +172,19 @@ def test_metasploit(test_client): "payload": "generic/shell_reverse_tcp", }, mock_out, + execution_context={ + "target_policy_id": policy["id"], + "scan_profile": "standard", + "validation_mode": "proof", + "evidence_level": "standard", + }, ) assert any("Metasploit Session Opened" in f["title"] for f in result["structured"]["findings"]) def test_sqli_checker(test_client, monkeypatch): monkeypatch.setattr(settings, "safe_mode_default", False) + policy = _create_target_policy(test_client) mock_out = "Payload: ' OR 1=1 --\navailable databases [2]:\nmain\naudit" result = run_plugin_test( test_client, @@ -163,6 +196,12 @@ def test_sqli_checker(test_client, monkeypatch): "technique": "BEUSTQ", }, mock_out, + execution_context={ + "target_policy_id": policy["id"], + "scan_profile": "standard", + "validation_mode": "proof", + "evidence_level": "standard", + }, ) findings = result["structured"]["findings"] assert any("SQL Injection Found" in f["title"] for f in findings) diff --git a/testing/backend/integration/test_request_id_middleware.py b/testing/backend/integration/test_request_id_middleware.py new file mode 100644 index 000000000..daa3859bb --- /dev/null +++ b/testing/backend/integration/test_request_id_middleware.py @@ -0,0 +1,65 @@ +import pytest +from unittest.mock import patch + +def test_request_id_present_on_success(test_client): + response = test_client.get("/api/v1/health") + assert response.status_code == 200 + assert "x-request-id" in response.headers + assert response.headers["x-request-id"] != "" + +def test_request_id_echoed_when_provided(test_client): + response = test_client.get("/api/v1/health", headers={"X-Request-ID": "my-trace-id"}) + assert response.status_code == 200 + assert response.headers["x-request-id"] == "my-trace-id" + +def test_request_id_present_on_404(test_client): + response = test_client.get("/api/v1/does-not-exist") + assert response.status_code == 404 + assert "x-request-id" in response.headers + assert response.headers["x-request-id"] != "" + # Verify JSON shape + assert response.headers["content-type"].startswith("application/json") + assert response.json()["detail"] == "Not Found" + +def test_request_id_present_on_422(test_client): + # POST with missing required fields triggers 422 validation error + response = test_client.post("/api/v1/task/start", json={}) + assert response.status_code == 422 + assert "x-request-id" in response.headers + assert response.headers["x-request-id"] != "" + # Verify JSON shape + assert response.headers["content-type"].startswith("application/json") + assert isinstance(response.json()["detail"], list) + +def test_request_id_present_on_unhandled_exception(test_client): + # Force an unhandled exception in a route handler by mocking platform.system. + # We create a local TestClient with raise_exceptions=False to ensure our + # global exception handler is exercised instead of Starlette re-raising. + from fastapi.testclient import TestClient + from backend.secuscan.main import app + from backend.secuscan import auth as auth_module + from backend.secuscan.config import settings + + api_key = auth_module.init_api_key(settings.data_dir) + client = TestClient(app, headers={"X-Api-Key": api_key}, raise_server_exceptions=False) + + with patch("platform.system", side_effect=RuntimeError("boom")): + response = client.get("/api/v1/health") + assert response.status_code == 500 + assert "x-request-id" in response.headers + assert response.headers["x-request-id"] != "" + + # Verify shape and header matches standard Starlette ServerErrorMiddleware + if settings.debug: + assert "text/html" in response.headers["content-type"] + assert "500 Internal Server Error" in response.text + else: + assert "text/plain" in response.headers["content-type"] + assert response.text == "Internal Server Error" + +def test_request_id_is_consistent_across_same_request(test_client): + # The ID in the response header should match what was set on the request state. + custom_id = "consistency-check-id" + response = test_client.get("/api/v1/health", headers={"X-Request-ID": custom_id}) + assert response.status_code == 200 + assert response.headers["x-request-id"] == custom_id diff --git a/testing/backend/integration/test_request_logging_smoke.py b/testing/backend/integration/test_request_logging_smoke.py new file mode 100644 index 000000000..7b2e4f5ff --- /dev/null +++ b/testing/backend/integration/test_request_logging_smoke.py @@ -0,0 +1,61 @@ +# testing/backend/integration/test_request_logging_smoke.py + +import io +import json +import logging +import re + +import pytest +from backend.secuscan.logging_utils import JSONFormatter, RequestIDFilter + +UUID4_RE = re.compile( + r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" +) + +@pytest.fixture +def log_capture(): + buf = io.StringIO() + handler = logging.StreamHandler(buf) + handler.addFilter(RequestIDFilter()) + handler.setFormatter(JSONFormatter()) + handler.setLevel(logging.DEBUG) + root = logging.getLogger() + root.addHandler(handler) + try: + yield buf + finally: + root.removeHandler(handler) + handler.close() + +def _parse_log_lines(buf): + return [json.loads(line) for line in buf.getvalue().splitlines() if line.strip()] + +def test_request_id_appears_in_json_logs(test_client, log_capture): + response = test_client.get("/api/v1/health") + assert response.status_code == 200 + + request_id = response.headers.get("X-Request-ID") + assert request_id, "Middleware must echo X-Request-ID header" + assert UUID4_RE.match(request_id), "Request ID must be a valid UUID4" + + entries = _parse_log_lines(log_capture) + assert entries, "At least one JSON log line must be emitted" + + for entry in entries: + for key in ("timestamp", "level", "request_id", "logger", "message"): + assert key in entry, f"Log entry missing key '{key}': {entry}" + + correlated = [e for e in entries if e["request_id"] == request_id] + assert correlated, ( + f"No log line carries request_id={request_id!r}. " + f"Seen IDs: {[e['request_id'] for e in entries]}" + ) + +def test_passthrough_request_id(test_client, log_capture): + custom_id = "smoke-test-trace-abc123" + response = test_client.get("/api/v1/health", headers={"X-Request-ID": custom_id}) + assert response.headers.get("X-Request-ID") == custom_id + + entries = _parse_log_lines(log_capture) + correlated = [e for e in entries if e["request_id"] == custom_id] + assert correlated, "Passthrough request ID must appear in logs" diff --git a/testing/backend/integration/test_routes.py b/testing/backend/integration/test_routes.py index 6766de5af..f1176bd13 100644 --- a/testing/backend/integration/test_routes.py +++ b/testing/backend/integration/test_routes.py @@ -25,6 +25,9 @@ def test_list_plugins(test_client): assert "availability" in first assert "runnable" in first["availability"] assert "missing_binaries" in first["availability"] + assert "implementation_status" in first + assert "supports_authenticated_crawling" in first + assert "supports_session_reuse" in first def test_plugin_summary(test_client): """Test plugin summary endpoint.""" @@ -79,6 +82,9 @@ def test_start_task(test_client): assert result_response.status_code == 200 result_data = result_response.json() assert "Mocked successful output" in result_data["raw_output_excerpt"] + assert "finding_groups" in result_data + assert "asset_summary" in result_data + assert "scan_diff" in result_data def test_missing_consent(test_client): """Test starting a task without consent.""" @@ -100,6 +106,7 @@ def test_get_settings(test_client): assert "network" in data assert "sandbox" in data assert "safety" in data + assert "execution_context" in data def test_start_task_missing_plugin(test_client): """Starting a task with a missing plugin should return 404 and helpful detail.""" diff --git a/testing/backend/integration/test_task_cleanup.py b/testing/backend/integration/test_task_cleanup.py index 797bfe29c..565933167 100644 --- a/testing/backend/integration/test_task_cleanup.py +++ b/testing/backend/integration/test_task_cleanup.py @@ -224,6 +224,34 @@ async def test_delete_running_task_returns_400(app_client): assert resp.status_code == 400, resp.text +@pytest.mark.asyncio +async def test_delete_task_rejected_when_in_executor_running_tasks(app_client): + """Deleting a task whose ID is in executor.running_tasks returns 400, + even if the DB status is 'queued' (i.e. the optimistic UPDATE hasn't fired yet). + + This exercises the in-memory safety check added alongside the race fix. + """ + db = app_client._db + task_id = await insert_task(db, status="queued") + + # Place the task ID directly into running_tasks to simulate the race window + # where execute_task has stored the task but the DB UPDATE hasn't run yet. + app_client._mock_executor.running_tasks = {task_id: "mock_task"} + app_client._mock_executor.get_task_status = AsyncMock( + return_value={"status": "queued"} + ) + + resp = await app_client.delete(f"/api/v1/task/{task_id}") + + assert resp.status_code == 400, resp.text + body = resp.json() + assert "running" in body["detail"].lower() + + # Verify the task still exists in the DB (was NOT deleted) + rows = await db_fetchall(app_client._db_path, "SELECT id FROM tasks WHERE id = ?", (task_id,)) + assert len(rows) == 1, "Task should NOT have been deleted" + + @pytest.mark.asyncio async def test_delete_missing_task_returns_200(app_client): """Deleting a task that doesn't exist returns 200 (route is idempotent). @@ -342,6 +370,67 @@ async def test_bulk_delete_with_running_task_returns_400(app_client): assert len(rows) == 1, f"Task {tid} should NOT have been deleted after 400" +@pytest.mark.asyncio +async def test_bulk_delete_atomicity_no_partial_delete_with_running_task(app_client): + """Atomicity: running-task rejection rolls back ALL deletes — no orphaned records.""" + db = app_client._db + db_path = app_client._db_path + + task_ok = await insert_task(db, status="completed") + finding_id = await insert_finding(db, task_ok) + report_id = await insert_report(db, task_ok) + await insert_audit_log(db, task_ok) + + task_running = await insert_task(db, status="running") + + resp = await app_client.request( + "DELETE", "/api/v1/tasks/bulk", json=[task_ok, task_running], + ) + assert resp.status_code == 400, resp.text + + # Task rows must survive + for tid in (task_ok, task_running): + rows = await db_fetchall(db_path, "SELECT id FROM tasks WHERE id = ?", (tid,)) + assert len(rows) == 1, f"Task {tid} should NOT have been deleted" + + # Associated records must survive + rows = await db_fetchall(db_path, "SELECT id FROM findings WHERE id = ?", (finding_id,)) + assert len(rows) == 1, "Finding should NOT have been deleted" + + rows = await db_fetchall(db_path, "SELECT id FROM reports WHERE id = ?", (report_id,)) + assert len(rows) == 1, "Report should NOT have been deleted" + + rows = await db_fetchall(db_path, "SELECT id FROM audit_log WHERE task_id = ?", (task_ok,)) + assert len(rows) >= 1, "Audit log should NOT have been deleted" + + +@pytest.mark.asyncio +async def test_bulk_delete_cascades_to_associated_records(app_client): + """Bulk delete removes findings, reports, and audit_log for each deleted task.""" + db = app_client._db + db_path = app_client._db_path + + task_id = await insert_task(db, status="completed") + finding_id = await insert_finding(db, task_id) + report_id = await insert_report(db, task_id) + await insert_audit_log(db, task_id) + + resp = await app_client.request( + "DELETE", "/api/v1/tasks/bulk", json=[task_id], + ) + assert resp.status_code == 200, resp.text + assert resp.json()["deleted_count"] == 1 + + rows = await db_fetchall(db_path, "SELECT id FROM findings WHERE id = ?", (finding_id,)) + assert len(rows) == 0, "Finding should have been deleted" + + rows = await db_fetchall(db_path, "SELECT id FROM reports WHERE id = ?", (report_id,)) + assert len(rows) == 0, "Report should have been deleted" + + rows = await db_fetchall(db_path, "SELECT id FROM audit_log WHERE task_id = ?", (task_id,)) + assert len(rows) == 0, "Audit log should have been deleted" + + # --------------------------------------------------------------------------- # Tests: DELETE /api/v1/tasks/clear # --------------------------------------------------------------------------- diff --git a/testing/backend/integration/test_uncover_plugin.py b/testing/backend/integration/test_uncover_plugin.py new file mode 100644 index 000000000..8afa13c79 --- /dev/null +++ b/testing/backend/integration/test_uncover_plugin.py @@ -0,0 +1,62 @@ +from unittest.mock import patch +import time + +from backend.secuscan.models import TaskStatus + +def run_plugin_test(test_client, plugin_id, inputs, mock_output): + """Helper to run a plugin test with mocked execution.""" + with patch("backend.secuscan.executor.TaskExecutor._execute_command") as mock_exec: + mock_exec.return_value = (mock_output, 0) + + payload = { + "plugin_id": plugin_id, + "inputs": inputs, + "consent_granted": True, + } + + # Start task + response = test_client.post("/api/v1/task/start", json=payload) + assert response.status_code == 200, f"Failed to start {plugin_id}: {response.text}" + task_id = response.json()["task_id"] + + # Wait for completion (since it's mocked, it should be fast) + # In the test environment, the executor might be running in the same thread or very fast + max_retries = 10 + for _ in range(max_retries): + status_response = test_client.get(f"/api/v1/task/{task_id}/status") + status = status_response.json()["status"] + if status == TaskStatus.COMPLETED.value: + break + time.sleep(0.1) + + assert status == TaskStatus.COMPLETED.value, f"Task {task_id} did not complete for {plugin_id}" + + # Check result + result_response = test_client.get(f"/api/v1/task/{task_id}/result") + assert result_response.status_code == 200 + return result_response.json() +def test_uncover_schema_accessible(test_client): + schema = test_client.get("/api/v1/plugin/uncover/schema") + + assert schema.status_code == 200 + +def test_uncover_parser_output(test_client): + mock_out = """api.example.com +exposed.example.com +""" + + result = run_plugin_test( + test_client, + "uncover", + { + "query": "org:example", + "limit": 10, + }, + mock_out, + ) + + findings = result["structured"]["findings"] + + assert len(findings) > 0 + + assert "api.example.com" in result["raw_output_excerpt"] \ No newline at end of file diff --git a/testing/backend/integration/test_workflow_owner_bola.py b/testing/backend/integration/test_workflow_owner_bola.py new file mode 100644 index 000000000..5e87d7328 --- /dev/null +++ b/testing/backend/integration/test_workflow_owner_bola.py @@ -0,0 +1,309 @@ +""" +Integration tests for per-user ownership of workflows and notification rules +(issue #961 — BOLA in workflow and notification rule CRUD). + +Two distinct users are simulated by sending different ``X-User-Id`` headers on +top of the shared deployment API key. The tests assert: + - Same-named workflows can coexist under different owners. + - User B can never list, read, update, delete, run, version, or rollback + User A's workflows (or notification rules). + - User A retains full access to their own resources. +""" + +import json +import sqlite3 +from unittest.mock import AsyncMock, patch + +import pytest + +from backend.secuscan.config import settings + + +ALICE = {"X-User-Id": "alice"} +BOB = {"X-User-Id": "bob"} + +ALICE_OWNER = "user:alice" +BOB_OWNER = "user:bob" + + +# --------------------------------------------------------------------------- +# DB helpers (direct SQL, bypasses the API for fixture setup) +# --------------------------------------------------------------------------- + +def _conn(): + return sqlite3.connect(settings.database_path) + + +def _seed_workflow(owner_id: str, workflow_id: str, name: str, + *, schedule_seconds=3600, enabled=1): + conn = _conn() + try: + conn.execute( + "INSERT INTO workflows (id, name, owner_id, schedule_seconds, enabled, steps_json) " + "VALUES (?, ?, ?, ?, ?, ?)", + (workflow_id, name, owner_id, schedule_seconds, enabled, + json.dumps([{"plugin_id": "http_inspector", "inputs": {"url": "http://example.com"}}])), + ) + conn.commit() + finally: + conn.close() + + +def _seed_workflow_version(workflow_id: str, version_number: int): + conn = _conn() + try: + conn.execute( + "INSERT INTO workflow_versions " + "(id, workflow_id, version_number, definition_json, created_by) " + "VALUES (?, ?, ?, ?, 'test')", + (f"v-{workflow_id}-{version_number}", workflow_id, version_number, + json.dumps({"name": "test", "enabled": True, "steps": []})), + ) + conn.commit() + finally: + conn.close() + + +def _seed_notification_rule(owner_id: str, rule_id: str, name: str): + conn = _conn() + try: + conn.execute( + "INSERT INTO notification_rules " + "(id, name, owner_id, severity_threshold, channel_type, target_url_or_email) " + "VALUES (?, ?, ?, 'medium', 'email', 'a@b.com')", + (rule_id, name, owner_id), + ) + conn.commit() + finally: + conn.close() + + +def _workflow_owner(workflow_id: str): + conn = _conn() + try: + cur = conn.execute("SELECT owner_id FROM workflows WHERE id = ?", (workflow_id,)) + row = cur.fetchone() + return row[0] if row else None + finally: + conn.close() + + +def _workflow_exists(workflow_id: str) -> bool: + conn = _conn() + try: + cur = conn.execute("SELECT 1 FROM workflows WHERE id = ?", (workflow_id,)) + return cur.fetchone() is not None + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Workflow fixtures — payload helper +# --------------------------------------------------------------------------- + +def _wf_payload(name: str = "Nightly Scan"): + return { + "name": name, + "schedule_seconds": 3600, + "enabled": True, + "steps": [{"plugin_id": "http_inspector", "inputs": {"url": "http://127.0.0.1:8000"}}], + } + + +# --------------------------------------------------------------------------- +# Same-name workflows across owners +# --------------------------------------------------------------------------- + +def test_same_name_workflows_allowed_across_owners(test_client): + """Two different owners can each create a workflow with the same name.""" + resp_a = test_client.post("/api/v1/workflows", json=_wf_payload("MyScan"), headers=ALICE) + assert resp_a.status_code == 200, resp_a.text + wf_a = resp_a.json() + + resp_b = test_client.post("/api/v1/workflows", json=_wf_payload("MyScan"), headers=BOB) + assert resp_b.status_code == 200, resp_b.text + wf_b = resp_b.json() + + assert wf_a["id"] != wf_b["id"] + assert wf_a["name"] == wf_b["name"] == "MyScan" + assert _workflow_owner(wf_a["id"]) == ALICE_OWNER + assert _workflow_owner(wf_b["id"]) == BOB_OWNER + + +# --------------------------------------------------------------------------- +# Cross-owner isolation — workflows +# --------------------------------------------------------------------------- + +def test_workflow_list_is_scoped_to_owner(test_client): + _seed_workflow(ALICE_OWNER, "wf-alice-1", "AliceWF") + _seed_workflow(BOB_OWNER, "wf-bob-1", "BobWF") + + alice_wfs = {w["id"] for w in test_client.get("/api/v1/workflows", headers=ALICE).json()["workflows"]} + bob_wfs = {w["id"] for w in test_client.get("/api/v1/workflows", headers=BOB).json()["workflows"]} + + assert "wf-alice-1" in alice_wfs and "wf-bob-1" not in alice_wfs + assert "wf-bob-1" in bob_wfs and "wf-alice-1" not in bob_wfs + + +def test_workflow_get_blocks_cross_owner(test_client): + _seed_workflow(ALICE_OWNER, "wf-alice-get", "AliceWF") + + resp = test_client.get("/api/v1/workflows/wf-alice-get", headers=BOB) + # The PR does not add a dedicated GET /workflows/{id} endpoint; use run as proxy. + # If a future GET endpoint uses _verify_workflow_owner, it will return 403. + # For now, verify via update (PATCH) and delete that these block cross-owner. + assert True + + +def test_workflow_update_blocks_cross_owner(test_client): + _seed_workflow(ALICE_OWNER, "wf-alice-upd", "AliceWF") + + resp = test_client.patch("/api/v1/workflows/wf-alice-upd", json={"enabled": False}, headers=BOB) + assert resp.status_code == 403, resp.text + + +def test_workflow_delete_blocks_cross_owner(test_client): + _seed_workflow(ALICE_OWNER, "wf-alice-del", "AliceWF") + + resp = test_client.delete("/api/v1/workflows/wf-alice-del", headers=BOB) + assert resp.status_code == 403, resp.text + # Workflow must still exist + assert _workflow_exists("wf-alice-del") + + +def test_workflow_run_blocks_cross_owner(test_client): + _seed_workflow(ALICE_OWNER, "wf-alice-run", "AliceWF", enabled=0) + + with patch("backend.secuscan.routes.executor.create_task", new=AsyncMock(return_value="t-1")), \ + patch("backend.secuscan.routes.executor.execute_task", new=AsyncMock()): + resp = test_client.post("/api/v1/workflows/wf-alice-run/run", headers=BOB) + assert resp.status_code == 403, resp.text + + +def test_workflow_runs_blocks_cross_owner(test_client): + _seed_workflow(ALICE_OWNER, "wf-alice-runs", "AliceWF") + + resp = test_client.get("/api/v1/workflows/wf-alice-runs/runs", headers=BOB) + assert resp.status_code == 403, resp.text + + +def test_workflow_versions_blocks_cross_owner(test_client): + _seed_workflow(ALICE_OWNER, "wf-alice-vers", "AliceWF") + _seed_workflow_version("wf-alice-vers", 1) + + resp = test_client.get("/api/v1/workflows/wf-alice-vers/versions", headers=BOB) + assert resp.status_code == 403, resp.text + + +def test_workflow_rollback_blocks_cross_owner(test_client): + _seed_workflow(ALICE_OWNER, "wf-alice-rb", "AliceWF") + _seed_workflow_version("wf-alice-rb", 1) + + resp = test_client.post("/api/v1/workflows/wf-alice-rb/rollback/1", headers=BOB) + assert resp.status_code == 403, resp.text + + +# --------------------------------------------------------------------------- +# Owners can access their own workflows +# --------------------------------------------------------------------------- + +def test_workflow_owner_can_update(test_client): + _seed_workflow(ALICE_OWNER, "wf-own-upd", "OwnWF") + + resp = test_client.patch("/api/v1/workflows/wf-own-upd", json={"enabled": False}, headers=ALICE) + assert resp.status_code == 200, resp.text + + +def test_workflow_owner_can_delete(test_client): + _seed_workflow(ALICE_OWNER, "wf-own-del", "OwnWF") + + resp = test_client.delete("/api/v1/workflows/wf-own-del", headers=ALICE) + assert resp.status_code == 200, resp.text + assert not _workflow_exists("wf-own-del") + + +# --------------------------------------------------------------------------- +# Cross-owner isolation — notification rules +# --------------------------------------------------------------------------- + +def test_notification_rule_list_is_scoped_to_owner(test_client): + _seed_notification_rule(ALICE_OWNER, "nr-alice", "AliceRule") + _seed_notification_rule(BOB_OWNER, "nr-bob", "BobRule") + + alice_rules = {r["id"] for r in test_client.get("/api/v1/notifications/rules", headers=ALICE).json()["rules"]} + bob_rules = {r["id"] for r in test_client.get("/api/v1/notifications/rules", headers=BOB).json()["rules"]} + + assert "nr-alice" in alice_rules and "nr-bob" not in alice_rules + assert "nr-bob" in bob_rules and "nr-alice" not in bob_rules + + +def test_notification_rule_get_blocks_cross_owner(test_client): + _seed_notification_rule(ALICE_OWNER, "nr-get", "RuleGet") + + resp = test_client.get("/api/v1/notifications/rules/nr-get", headers=BOB) + assert resp.status_code == 403, resp.text + + +def test_notification_rule_update_blocks_cross_owner(test_client): + _seed_notification_rule(ALICE_OWNER, "nr-upd", "RuleUpd") + + resp = test_client.patch( + "/api/v1/notifications/rules/nr-upd", + json={"severity_threshold": "high"}, + headers=BOB, + ) + assert resp.status_code == 403, resp.text + + +def test_notification_rule_delete_blocks_cross_owner(test_client): + _seed_notification_rule(ALICE_OWNER, "nr-del", "RuleDel") + + resp = test_client.delete("/api/v1/notifications/rules/nr-del", headers=BOB) + assert resp.status_code == 403, resp.text + + # Must still exist + conn = _conn() + try: + cur = conn.execute("SELECT 1 FROM notification_rules WHERE id = 'nr-del'") + assert cur.fetchone() is not None + finally: + conn.close() + + +def test_notification_rule_owner_can_update(test_client): + _seed_notification_rule(ALICE_OWNER, "nr-own-upd", "OwnRule") + + resp = test_client.patch( + "/api/v1/notifications/rules/nr-own-upd", + json={"severity_threshold": "high"}, + headers=ALICE, + ) + assert resp.status_code == 200, resp.text + + +def test_notification_rule_owner_can_delete(test_client): + _seed_notification_rule(ALICE_OWNER, "nr-own-del", "OwnRule") + + resp = test_client.delete("/api/v1/notifications/rules/nr-own-del", headers=ALICE) + assert resp.status_code == 200, resp.text + + conn = _conn() + try: + cur = conn.execute("SELECT 1 FROM notification_rules WHERE id = 'nr-own-del'") + assert cur.fetchone() is None + finally: + conn.close() + + +# --------------------------------------------------------------------------- +# Unknown / missing resources return 404, not 403 +# --------------------------------------------------------------------------- + +def test_unknown_workflow_returns_404_not_403(test_client): + resp = test_client.get("/api/v1/workflows/does-not-exist/runs", headers=BOB) + assert resp.status_code == 404, resp.text + + +def test_unknown_notification_rule_returns_404_not_403(test_client): + resp = test_client.get("/api/v1/notifications/rules/does-not-exist", headers=BOB) + assert resp.status_code == 404, resp.text diff --git a/testing/backend/integration/test_workflows_contract.py b/testing/backend/integration/test_workflows_contract.py index e4c0da92d..067319bcd 100644 --- a/testing/backend/integration/test_workflows_contract.py +++ b/testing/backend/integration/test_workflows_contract.py @@ -14,12 +14,25 @@ def test_workflow_create_list_update_contract(test_client): create_response = test_client.post("/api/v1/workflows", json=_workflow_payload()) assert create_response.status_code == 200 created = create_response.json() + expected_step = { + "plugin_id": "http_inspector", + "inputs": {"url": "http://127.0.0.1:8000"}, + "preset": None, + "execution_context": { + "target_policy_id": None, + "scan_profile": "standard", + "credential_profile_id": None, + "session_profile_id": None, + "validation_mode": "proof", + "evidence_level": "standard", + }, + } assert created["id"] assert created["name"] == "Nightly Scan" assert created["schedule_seconds"] == 3600 assert created["enabled"] is True - assert created["steps"] == [{"plugin_id": "http_inspector", "inputs": {"url": "http://127.0.0.1:8000"}}] + assert created["steps"] == [expected_step] assert created["queued_task_ids"] == [] assert "steps_json" not in created diff --git a/testing/backend/test_amass_plugin.py b/testing/backend/test_amass_plugin.py new file mode 100644 index 000000000..0a7702253 --- /dev/null +++ b/testing/backend/test_amass_plugin.py @@ -0,0 +1,241 @@ +""" +Contract and parser tests for the amass plugin. + +These tests load the real plugins/amass/metadata.json, validate it +through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #489: Add parser and contract coverage for plugin `amass` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.amass.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "amass" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_amass_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_amass_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_amass_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_amass_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "amass" + + +def test_amass_engine_is_amass(): + """Engine binary must be 'amass'.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "amass" + + +def test_amass_has_required_target_field(): + """Plugin must declare a required 'target' field.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_amass_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_amass_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +def test_amass_does_not_require_consent(): + """Amass scanning is recon and does not require consent.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["safety"]["requires_consent"] is False + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_amass_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct amass command for a target. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("amass", {"target": "secuscan.in"}) + + assert command is not None, "build_command returned None for valid inputs" + assert command[0] == "amass" + assert "enum" in command + assert "-d" in command + assert "secuscan.in" in command + assert "-dir" in command + assert "/tmp/amass" in command + assert "-silent" in command + + +def test_amass_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("amass", {"target": "secuscan.in"}) + + assert command == [ + "amass", + "enum", + "-d", + "secuscan.in", + "-dir", + "/tmp/amass", + "-silent", + ], f"Command template drift detected. Got: {command}" + + +def test_amass_drops_target_token_when_absent(setup_test_environment): + """ + When the 'target' field is omitted, the renderer drops the unresolved + {target} token rather than emitting an empty value or literal placeholder. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + rendered = manager.build_command("amass", {}) + + assert rendered is not None + assert not any("{" in token for token in rendered), "Unresolved placeholder leaked" + assert rendered == ["amass", "enum", "-d", "-dir", "/tmp/amass", "-silent"] + + populated = manager.build_command("amass", {"target": "secuscan.in"}) + assert "secuscan.in" in populated + assert len(populated) == len(rendered) + 1 + + +def test_amass_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load amass from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("amass") + assert plugin is not None + assert plugin.id == "amass" + assert plugin.name == "Amass" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_AMASS_OUTPUT_FIXTURE = ( + "secuscan.in\n" + "api.secuscan.in [alive]\n" + "dev.secuscan.in\n" + "admin.secuscan.in [exposed]\n" +) + + +def test_amass_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_AMASS_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_amass_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_AMASS_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_amass_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_AMASS_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ( + "title", + "category", + "severity", + "description", + "remediation", + "metadata", + ): + assert key in finding, f"Finding missing key: {key}" + + +def test_amass_parser_severity_classification(): + """Lines with keywords (alive, exposed) must be 'low' severity, others 'info'.""" + result = parse(_AMASS_OUTPUT_FIXTURE) + findings = result["findings"] + assert len(findings) == 4 + + # "secuscan.in" -> info + assert findings[0]["severity"] == "info" + # "api.secuscan.in [alive]" -> low + assert findings[1]["severity"] == "low" + # "dev.secuscan.in" -> info + assert findings[2]["severity"] == "info" + # "admin.secuscan.in [exposed]" -> low + assert findings[3]["severity"] == "low" + + +def test_amass_parser_empty_output(): + """Parser must handle empty input and return empty findings without raising.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_amass_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw_line must match the original output line.""" + single_line = "sub.secuscan.in [exposed]\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw_line"] == "sub.secuscan.in [exposed]" diff --git a/testing/backend/test_api_scanner_plugin.py b/testing/backend/test_api_scanner_plugin.py new file mode 100644 index 000000000..d2a42840d --- /dev/null +++ b/testing/backend/test_api_scanner_plugin.py @@ -0,0 +1,287 @@ +""" +Contract and parser tests for the api_scanner plugin. + +These tests load the real plugins/api_scanner/metadata.json, validate it +through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #490: Add parser and contract coverage for plugin `api_scanner` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.api_scanner.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "api_scanner" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_api_scanner_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_api_scanner_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_api_scanner_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + + This will fail if any required field is missing, the engine type or safety + level is invalid, the command template references an undeclared field, or + the checksum field is absent or malformed. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, ( + "Plugin validation errors:\n" + + "\n".join(e.display() for e in result.errors) + ) + + +def test_api_scanner_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "api_scanner" + + +def test_api_scanner_engine_is_nuclei(): + """Engine binary must be 'nuclei' -- update this if the underlying tool changes.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "nuclei" + + +def test_api_scanner_has_required_target_field(): + """Plugin must declare a required 'target' field for the API base URL.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_api_scanner_target_field_uses_url_validation_preset(): + """The 'target' field must use the named url validation preset (issue #537).""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + target_validation = fields["target"].get("validation", {}) + assert target_validation.get("validation_type") == "url" + assert target_validation.get("message") == "Must be a valid HTTP(S) URL" + assert "pattern" not in target_validation + + +def test_api_scanner_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_api_scanner_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +def test_api_scanner_requires_consent(): + """API scanning is intrusive and must require user consent.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["safety"]["requires_consent"] is True + assert data["safety"]["consent_message"], "consent_message must not be empty" + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_api_scanner_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct nuclei command for an API target. + + This test will fail if command_template in metadata.json changes or a + placeholder becomes mismatched. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("api_scanner", {"target": "https://api.example.com"}) + + assert command is not None, "build_command returned None for valid inputs" + assert command[0] == "nuclei" + assert "-u" in command + assert "https://api.example.com" in command + assert "-silent" in command + + +def test_api_scanner_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("api_scanner", {"target": "https://api.secuscan.in"}) + + assert command == ["nuclei", "-u", "https://api.secuscan.in", "-silent"], ( + f"Command template drift detected. Got: {command}" + ) + + +def test_api_scanner_drops_target_token_when_absent(setup_test_environment): + """ + When the 'target' field is omitted, the renderer drops the unresolved + {target} token rather than emitting an empty value or literal placeholder. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + rendered = manager.build_command("api_scanner", {}) + + assert rendered is not None + assert not any("{" in token for token in rendered), "Unresolved placeholder leaked" + assert rendered == ["nuclei", "-u", "-silent"] + + populated = manager.build_command("api_scanner", {"target": "https://api.example.com"}) + assert "https://api.example.com" in populated + assert len(populated) == len(rendered) + 1 + + +def test_api_scanner_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load api_scanner from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("api_scanner") + assert plugin is not None + assert plugin.id == "api_scanner" + assert plugin.name == "API Scanner" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_API_SCAN_TEXT_FIXTURE = ( + "https://api.example.com/v1/users [GET] [critical] [exposed]\n" + "https://api.example.com/admin [GET] [injection] [critical]\n" + "https://api.example.com/health [GET] [200 OK]\n" + "https://api.example.com/graphql [POST] [warning] [detected]\n" +) + + +def test_api_scanner_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_API_SCAN_TEXT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_api_scanner_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_API_SCAN_TEXT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_api_scanner_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_API_SCAN_TEXT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding, f"Finding missing key: {key}" + + +def test_api_scanner_parser_critical_keyword_raises_severity(): + """Lines containing 'critical' or 'injection' must be classified as 'high' severity.""" + result = parse(_API_SCAN_TEXT_FIXTURE) + high_findings = [f for f in result["findings"] if f["severity"] == "high"] + assert len(high_findings) >= 1, "Expected at least one high-severity finding from critical/injection lines" + + +def test_api_scanner_parser_low_severity_for_exposed(): + """Lines containing 'exposed' or 'found' but not critical keywords must be 'low' severity.""" + result = parse(_API_SCAN_TEXT_FIXTURE) + exposed_lines = [f for f in result["findings"] if "exposed" in f["description"].lower()] + for finding in exposed_lines: + assert finding["severity"] in ("low", "high"), ( + f"Unexpected severity '{finding['severity']}' for exposed finding" + ) + + +def test_api_scanner_parser_empty_output(): + """Parser must handle empty input and return empty findings without raising.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_api_scanner_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw must match the original output line.""" + single_line = "https://api.example.com/v1/tokens [GET] [exposed]\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw"] == "https://api.example.com/v1/tokens [GET] [exposed]" + +def test_api_scanner_parser_handles_paginated_fixture(): + """ + Simulate paginated scanner output arriving in multiple chunks. + The parser should process all lines across pages. + """ + page_1 = ( + "https://api.example.com/v1/users [GET] [found]\n" + "https://api.example.com/v1/admin [GET] [warning]\n" + ) + + page_2 = ( + "https://api.example.com/v1/health [GET] [200 OK]\n" + "https://api.example.com/graphql [POST] [detected]\n" + ) + + combined_output = page_1 + page_2 + + result = parse(combined_output) + + assert result["count"] == 4 + assert len(result["findings"]) == 4 + assert len(result["items"]) == 4 + +def test_api_scanner_parser_handles_chunked_response_fixture(): + """ + Simulate chunked scanner output that is concatenated before parsing. + """ + chunks = [ + "https://api.example.com/a [GET] [found]\n", + "https://api.example.com/b [GET] [warning]\n", + "https://api.example.com/c [GET] [critical]\n", + ] + + result = parse("".join(chunks)) + + assert result["count"] == 3 + + severities = {f["severity"] for f in result["findings"]} + + assert "low" in severities + assert "high" in severities diff --git a/testing/backend/test_cloud_scanner_plugin.py b/testing/backend/test_cloud_scanner_plugin.py new file mode 100644 index 000000000..e14cd2d5e --- /dev/null +++ b/testing/backend/test_cloud_scanner_plugin.py @@ -0,0 +1,240 @@ +""" +Contract and parser tests for the cloud_scanner plugin. + +These tests load the real plugins/cloud_scanner/metadata.json, validate it +through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #491: Add parser and contract coverage for plugin `cloud_scanner` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.cloud_scanner.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "cloud_scanner" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_cloud_scanner_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_cloud_scanner_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_cloud_scanner_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + + This will fail if any required field is missing, the engine type or safety + level is invalid, the command template references an undeclared field, or + the checksum field is absent or malformed. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, ( + "Plugin validation errors:\n" + + "\n".join(e.display() for e in result.errors) + ) + + +def test_cloud_scanner_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "cloud_scanner" + + +def test_cloud_scanner_engine_is_python3(): + """Engine binary must be 'python3' -- update this if the underlying tool changes.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "python3" + + +def test_cloud_scanner_has_required_target_field(): + """Plugin must declare a required 'target' field for the cloud account/project.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_cloud_scanner_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_cloud_scanner_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +def test_cloud_scanner_requires_consent(): + """Cloud scanning is intrusive and must require user consent.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["safety"]["requires_consent"] is True + assert data["safety"]["consent_message"], "consent_message must not be empty" + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_cloud_scanner_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct command for a cloud account target. + + This test will fail if command_template in metadata.json changes or a + placeholder becomes mismatched. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("cloud_scanner", {"target": "org-example"}) + + assert command is not None, "build_command returned None for valid inputs" + assert "python3" in command + assert "org-example" in command + + +def test_cloud_scanner_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("cloud_scanner", {"target": "my-org"}) + + assert command is not None + assert command[0] == "python3" + assert command[-1] == "my-org", ( + f"Last token must be the interpolated target. Got: {command}" + ) + + +def test_cloud_scanner_drops_target_token_when_absent(setup_test_environment): + """ + When the 'target' field is omitted, the trailing {target} token is dropped + rather than emitting an empty value or literal placeholder. The python3 -c + scaffold (which references sys.argv[1]) is preserved. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + rendered = manager.build_command("cloud_scanner", {}) + + assert rendered is not None + assert not any("{" in token for token in rendered), "Unresolved placeholder leaked" + assert rendered[0] == "python3" + assert "-c" in rendered + # The trailing positional target argument is absent + assert "my-org" not in rendered + + populated = manager.build_command("cloud_scanner", {"target": "my-org"}) + assert populated[-1] == "my-org" + assert len(populated) == len(rendered) + 1 + + +def test_cloud_scanner_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load cloud_scanner from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("cloud_scanner") + assert plugin is not None + assert plugin.id == "cloud_scanner" + assert plugin.name == "Cloud Scanner" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_CLOUD_SCAN_TEXT_FIXTURE = ( + "Cloud scan baseline checks\n" + "target=my-org\n" + "providers=aws,gcp,azure\n" + "found exposed S3 bucket: my-org-public-data\n" + "warning: IAM role over-permissioned\n" + "critical: public RDS instance detected\n" +) + + +def test_cloud_scanner_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_CLOUD_SCAN_TEXT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_cloud_scanner_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_CLOUD_SCAN_TEXT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_cloud_scanner_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_CLOUD_SCAN_TEXT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding, f"Finding missing key: {key}" + + +def test_cloud_scanner_parser_critical_keyword_raises_to_high(): + """Lines containing 'critical' must be classified as 'high' severity.""" + result = parse(_CLOUD_SCAN_TEXT_FIXTURE) + critical_findings = [f for f in result["findings"] if "critical" in f["description"].lower()] + assert critical_findings, "No findings from critical line" + for finding in critical_findings: + assert finding["severity"] == "high" + + +def test_cloud_scanner_parser_found_keyword_raises_to_low(): + """Lines containing 'found' or 'warning' must be at least 'low' severity.""" + result = parse(_CLOUD_SCAN_TEXT_FIXTURE) + low_or_high = [f for f in result["findings"] if f["severity"] in ("low", "high")] + assert low_or_high, "Expected at least one non-info finding" + + +def test_cloud_scanner_parser_empty_output(): + """Parser must handle empty input without raising and return empty findings.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_cloud_scanner_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw must match the original output line.""" + single_line = "critical: public RDS instance detected\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw"] == "critical: public RDS instance detected" diff --git a/testing/backend/test_cloud_storage_auditor_plugin.py b/testing/backend/test_cloud_storage_auditor_plugin.py new file mode 100644 index 000000000..6aee1d820 --- /dev/null +++ b/testing/backend/test_cloud_storage_auditor_plugin.py @@ -0,0 +1,353 @@ +""" +Contract and parser tests for the cloud_storage_auditor plugin. + +These tests load the real plugins/cloud_storage_auditor/metadata.json, validate +it through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #492: Add parser and contract coverage for plugin `cloud_storage_auditor` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.cloud_storage_auditor.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "cloud_storage_auditor" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_cloud_storage_auditor_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_cloud_storage_auditor_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_cloud_storage_auditor_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + + This will fail if any required field is missing, the engine type or safety + level is invalid, the command template references an undeclared field, or + the checksum field is absent or malformed. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, ( + "Plugin validation errors:\n" + + "\n".join(e.display() for e in result.errors) + ) + + +def test_cloud_storage_auditor_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "cloud_storage_auditor" + + +def test_cloud_storage_auditor_engine_is_uncover(): + """Engine binary must be 'uncover' -- update this if the underlying tool changes.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "uncover" + + +def test_cloud_storage_auditor_has_required_query_field(): + """Plugin must declare a required 'query' field for the search query.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "query" in fields, "Missing required field: query" + assert fields["query"]["required"] is True + + +def test_cloud_storage_auditor_has_optional_limit_field_with_default(): + """Plugin must declare an optional 'limit' field with a default of 100.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "limit" in fields, "Missing optional field: limit" + assert fields["limit"].get("default") == 100, "limit default must be 100" + + +def test_cloud_storage_auditor_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_cloud_storage_auditor_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_cloud_storage_auditor_command_renders_with_query(setup_test_environment): + """ + PluginManager must produce the correct uncover command for a storage query. + + This test will fail if command_template in metadata.json changes or a + placeholder becomes mismatched. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + "cloud_storage_auditor", + {"query": "s3.amazonaws.com org:example"}, + ) + + assert command is not None, "build_command returned None for valid inputs" + assert "uncover" in command + assert "-q" in command + assert "s3.amazonaws.com org:example" in command + assert "-silent" in command + + +def test_cloud_storage_auditor_command_uses_default_limit(setup_test_environment): + """ + When 'limit' is omitted, the command must use the default value from metadata.json (100). + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + "cloud_storage_auditor", + {"query": "s3.amazonaws.com org:example"}, + ) + + assert command is not None + assert "-limit" in command + limit_idx = command.index("-limit") + assert command[limit_idx + 1] == "100", ( + f"Default limit must be '100'. Got: {command[limit_idx + 1]}" + ) + + +def test_cloud_storage_auditor_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + "cloud_storage_auditor", + {"query": "s3.amazonaws.com"}, + ) + + assert command == ["uncover", "-q", "s3.amazonaws.com", "-limit", "100", "-silent"], ( + f"Command template drift detected. Got: {command}" + ) + + +def test_cloud_storage_auditor_command_respects_explicit_limit(setup_test_environment): + """When 'limit' is explicitly provided, it must override the default.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + "cloud_storage_auditor", + {"query": "s3.amazonaws.com", "limit": 50}, + ) + + assert command is not None + limit_idx = command.index("-limit") + assert command[limit_idx + 1] == "50" + + +def test_cloud_storage_auditor_drops_query_token_when_absent(setup_test_environment): + """ + When the 'query' field is omitted, the renderer drops the unresolved + {query} token rather than emitting an empty value or literal placeholder. + The default limit scaffold is preserved. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + rendered = manager.build_command("cloud_storage_auditor", {}) + + assert rendered is not None + assert not any("{" in token for token in rendered), "Unresolved placeholder leaked" + assert rendered == ["uncover", "-q", "-limit", "100", "-silent"] + + populated = manager.build_command( + "cloud_storage_auditor", {"query": "s3.amazonaws.com"} + ) + assert "s3.amazonaws.com" in populated + assert len(populated) == len(rendered) + 1 + + +def test_cloud_storage_auditor_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load cloud_storage_auditor.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("cloud_storage_auditor") + assert plugin is not None + assert plugin.id == "cloud_storage_auditor" + assert plugin.name == "S3 / Blob Auditor" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_STORAGE_AUDIT_TEXT_FIXTURE = ( + "s3.amazonaws.com org:example-public\n" + "found exposed bucket: example-public-assets\n" + "warning: public-read ACL detected on example-public-assets\n" + "critical: bucket exposed sensitive documents\n" + "blob.core.windows.net container:org-backup\n" +) + + +def test_cloud_storage_auditor_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_STORAGE_AUDIT_TEXT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_cloud_storage_auditor_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_STORAGE_AUDIT_TEXT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_cloud_storage_auditor_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_STORAGE_AUDIT_TEXT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding, f"Finding missing key: {key}" + + +def test_cloud_storage_auditor_parser_critical_keyword_raises_to_high(): + """Lines containing 'critical' must be classified as 'high' severity.""" + result = parse(_STORAGE_AUDIT_TEXT_FIXTURE) + critical_findings = [f for f in result["findings"] if "critical" in f["description"].lower()] + assert critical_findings, "No findings from the critical line" + for finding in critical_findings: + assert finding["severity"] == "high" + + +def test_cloud_storage_auditor_parser_found_or_exposed_is_low(): + """Lines containing 'found' or 'exposed' must be at least 'low' severity.""" + result = parse(_STORAGE_AUDIT_TEXT_FIXTURE) + exposed_findings = [ + f for f in result["findings"] + if "exposed" in f["description"].lower() or "found" in f["description"].lower() + ] + assert exposed_findings, "Expected findings from exposed/found lines" + for finding in exposed_findings: + assert finding["severity"] in ("low", "high") + + +def test_cloud_storage_auditor_parser_empty_output(): + """Parser must handle empty input without raising and return empty findings.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_cloud_storage_auditor_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw must match the original output line.""" + single_line = "found exposed bucket: example-data\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw"] == "found exposed bucket: example-data" + + +# Coverage for multi-bucket / multi-container result sets. +_MULTI_BUCKET_LINES = [ + "s3.amazonaws.com bucket:acme-public-assets", + "acme-public-assets.s3.amazonaws.com found exposed public-read ACL", + "s3.amazonaws.com bucket:acme-logs-prod", + "blob.core.windows.net container:acme-backups", + "acme-backups.blob.core.windows.net warning: anonymous access detected", + "storage.googleapis.com bucket:acme-gcs-archive", + "critical: acme-gcs-archive exposed customer PII", +] +_MULTI_BUCKET_FIXTURE = "\n".join(_MULTI_BUCKET_LINES) + "\n" + + +def test_cloud_storage_auditor_parser_multi_bucket_count_matches_lines(): + """Every bucket/container line in a multi-result set yields exactly one finding.""" + result = parse(_MULTI_BUCKET_FIXTURE) + assert result["count"] == len(_MULTI_BUCKET_LINES) + assert len(result["findings"]) == len(_MULTI_BUCKET_LINES) + assert result["items"] == _MULTI_BUCKET_LINES + + +def test_cloud_storage_auditor_parser_multi_bucket_preserves_each_line_in_order(): + """Each finding maps to its source line, in order, with the raw line preserved.""" + result = parse(_MULTI_BUCKET_FIXTURE) + for finding, line in zip(result["findings"], _MULTI_BUCKET_LINES): + assert finding["description"] == line + assert finding["metadata"]["raw"] == line + + +def test_cloud_storage_auditor_parser_multi_bucket_covers_multiple_providers(): + """The fixture spans the S3, Azure Blob, and GCS host families.""" + blob = "\n".join(parse(_MULTI_BUCKET_FIXTURE)["items"]) + assert "s3.amazonaws.com" in blob + assert "blob.core.windows.net" in blob + assert "storage.googleapis.com" in blob + + +def test_cloud_storage_auditor_parser_multi_bucket_distinct_identifiers_present(): + """Each distinct bucket/container identifier survives parsing.""" + blob = "\n".join(parse(_MULTI_BUCKET_FIXTURE)["items"]) + for identifier in ( + "acme-public-assets", + "acme-logs-prod", + "acme-backups", + "acme-gcs-archive", + ): + assert identifier in blob + + +def test_cloud_storage_auditor_parser_multi_bucket_severity_classified_per_line(): + """ + Severity is classified independently per bucket — a 'critical' line must not + raise the severity of unrelated plain-listing lines (no cross-line bleed). + """ + severities = [f["severity"] for f in parse(_MULTI_BUCKET_FIXTURE)["findings"]] + # plain host listings stay info despite later critical/exposed lines + assert severities[0] == "info" + assert severities[2] == "info" + assert severities[3] == "info" + # found / exposed / warning -> low + assert severities[1] == "low" + assert severities[4] == "low" + # critical -> high + assert severities[6] == "high" + # the multi-result set exercises all three severity tiers + assert set(severities) == {"info", "low", "high"} diff --git a/testing/backend/test_container_scanner_plugin.py b/testing/backend/test_container_scanner_plugin.py new file mode 100644 index 000000000..692f52d44 --- /dev/null +++ b/testing/backend/test_container_scanner_plugin.py @@ -0,0 +1,261 @@ +""" +Contract and parser tests for the container_scanner plugin. + +These tests load the real plugins/container_scanner/metadata.json, validate +it through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +The assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #493: Add parser and contract coverage for plugin `container_scanner` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.container_scanner.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "container_scanner" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_container_scanner_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_container_scanner_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_container_scanner_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + + This test will fail if any required field is missing, the engine type or + safety level is invalid, the command template references an undeclared field, + or the checksum field is absent or malformed. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, ( + "Plugin validation errors:\n" + + "\n".join(e.display() for e in result.errors) + ) + + +def test_container_scanner_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "container_scanner" + + +def test_container_scanner_engine_is_trivy(): + """Engine binary must be 'trivy' -- update this if the underlying tool changes.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "trivy" + + +def test_container_scanner_has_required_target_field(): + """Plugin must declare a required 'target' field for the Docker image.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_container_scanner_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_container_scanner_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_container_scanner_command_renders_with_image_target(setup_test_environment): + """ + PluginManager must produce the exact Trivy command for a Docker image target. + + This test will fail if command_template in metadata.json is changed or if + a placeholder becomes mismatched with the declared fields. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("container_scanner", {"target": "ubuntu:latest"}) + + assert command is not None, "build_command returned None for valid inputs" + assert command[0] == "trivy" + assert "image" in command + assert "-f" in command and "json" in command + assert "--no-progress" in command + assert "ubuntu:latest" in command + + +def test_container_scanner_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("container_scanner", {"target": "alpine:3.15"}) + + assert command == ["trivy", "image", "-f", "json", "--no-progress", "alpine:3.15"], ( + f"Command template drift detected. Got: {command}" + ) + + +def test_container_scanner_drops_target_token_when_absent(setup_test_environment): + """ + When the 'target' field is omitted, the renderer drops the unresolved + {target} token rather than emitting an empty or literal placeholder. + + This proves no image argument is fabricated when nothing is supplied, and + contrasts with the populated render where the image is the final argument. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + rendered = manager.build_command("container_scanner", {}) + + assert rendered is not None + assert not any("{" in token for token in rendered), "Unresolved placeholder leaked" + assert rendered == ["trivy", "image", "-f", "json", "--no-progress"] + + populated = manager.build_command("container_scanner", {"target": "ubuntu:latest"}) + assert populated[-1] == "ubuntu:latest" + assert len(populated) == len(rendered) + 1 + + +def test_container_scanner_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load container_scanner from the real plugins dir.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("container_scanner") + assert plugin is not None + assert plugin.id == "container_scanner" + assert plugin.name == "Container Scan (Trivy)" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_TRIVY_JSON_FIXTURE = json.dumps({ + "Results": [ + { + "Target": "ubuntu:latest (ubuntu 22.04)", + "Vulnerabilities": [ + { + "VulnerabilityID": "CVE-2024-1234", + "PkgName": "libssl1.1", + "Severity": "HIGH", + "Title": "OpenSSL buffer overflow in libssl1.1", + "Description": "Heap-based buffer overflow in libssl1.1 allows RCE.", + "InstalledVersion": "1.1.1f-1ubuntu2", + "FixedVersion": "1.1.1f-1ubuntu2.23", + "CVSS": {"nvd": {"V3Score": 9.8}}, + }, + { + "VulnerabilityID": "CVE-2024-5678", + "PkgName": "curl", + "Severity": "MEDIUM", + "Title": "SSRF in curl", + "Description": "Server-side request forgery in curl.", + "InstalledVersion": "7.81.0-1ubuntu1.13", + "FixedVersion": "7.81.0-1ubuntu1.16", + "CVSS": {}, + }, + ], + } + ] +}) + + +def test_container_scanner_parser_returns_findings_key(): + """parse() must return a dict with a 'findings' key.""" + result = parse(_TRIVY_JSON_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + + +def test_container_scanner_parser_extracts_both_vulnerabilities(): + """Parser must extract one finding per CVE entry in the Trivy output.""" + result = parse(_TRIVY_JSON_FIXTURE) + assert len(result["findings"]) == 2 + + +def test_container_scanner_parser_normalizes_high_severity(): + """'HIGH' Trivy severity must map to 'high' in the normalized findings.""" + result = parse(_TRIVY_JSON_FIXTURE) + high_findings = [f for f in result["findings"] if f["severity"] == "high"] + assert len(high_findings) == 1 + assert high_findings[0]["metadata"]["cve"] == "CVE-2024-1234" + + +def test_container_scanner_parser_normalizes_medium_severity(): + """'MEDIUM' Trivy severity must map to 'medium' in the normalized findings.""" + result = parse(_TRIVY_JSON_FIXTURE) + medium_findings = [f for f in result["findings"] if f["severity"] == "medium"] + assert len(medium_findings) == 1 + + +def test_container_scanner_parser_finding_has_required_keys(): + """Each finding must contain title, category, severity, description, remediation, metadata.""" + result = parse(_TRIVY_JSON_FIXTURE) + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding, f"Finding missing required key: {key}" + + +def test_container_scanner_parser_category_is_container_vulnerability(): + """Category must be 'Container Vulnerability' for all findings.""" + result = parse(_TRIVY_JSON_FIXTURE) + for finding in result["findings"]: + assert finding["category"] == "Container Vulnerability" + + +def test_container_scanner_parser_remediation_includes_package_name(): + """Remediation text must include the affected package name.""" + result = parse(_TRIVY_JSON_FIXTURE) + ssl_finding = next(f for f in result["findings"] if f["metadata"]["package"] == "libssl1.1") + assert "libssl1.1" in ssl_finding["remediation"] + + +def test_container_scanner_parser_empty_output_returns_empty_findings(): + """Parser must handle empty input without raising.""" + result = parse("") + assert result == {"findings": []} + + +def test_container_scanner_parser_handles_no_vulnerabilities(): + """Parser must return empty findings when Results has no vulnerabilities.""" + output = json.dumps({"Results": [{"Target": "alpine:3.15", "Vulnerabilities": []}]}) + result = parse(output) + assert result["findings"] == [] diff --git a/testing/backend/test_crawler_plugin.py b/testing/backend/test_crawler_plugin.py new file mode 100644 index 000000000..93ef65d9e --- /dev/null +++ b/testing/backend/test_crawler_plugin.py @@ -0,0 +1,303 @@ +""" +Contract and parser tests for the crawler plugin. + +These tests load the real plugins/crawler/metadata.json, validate it through +the project PluginMetadataValidator, render commands through the real +PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #494: Add parser and contract coverage for plugin `crawler` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.crawler.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "crawler" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_crawler_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_crawler_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_crawler_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + + This will fail if any required field is missing, the engine type or safety + level is invalid, the command template references an undeclared field, or + the checksum field is absent or malformed. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, ( + "Plugin validation errors:\n" + + "\n".join(e.display() for e in result.errors) + ) + + +def test_crawler_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "crawler" + + +def test_crawler_engine_is_katana(): + """Engine binary must be 'katana' -- update this if the underlying tool changes.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "katana" + + +def test_crawler_has_required_target_field(): + """Plugin must declare a required 'target' field for the URL to crawl.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_crawler_target_field_requires_http_url(): + """The 'target' field must validate for an HTTP(S) URL.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + target_validation = fields["target"].get("validation", {}) + pattern = target_validation.get("pattern", "") + assert "https?" in pattern or "http" in pattern, ( + "target field must validate for HTTP(S) URL format" + ) + + +def test_crawler_has_optional_depth_field_with_default(): + """Plugin must declare an optional 'depth' field with a default of 2.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "depth" in fields, "Missing optional field: depth" + assert fields["depth"].get("default") == 2, "depth default must be 2" + + +def test_crawler_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_crawler_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +def test_crawler_requires_consent(): + """Web crawling is intrusive and must require user consent.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["safety"]["requires_consent"] is True + assert data["safety"]["consent_message"], "consent_message must not be empty" + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_crawler_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct katana command for a crawl target. + + This test will fail if command_template in metadata.json changes or a + placeholder becomes mismatched. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("crawler", {"target": "https://example.com"}) + + assert command is not None, "build_command returned None for valid inputs" + assert "katana" in command + assert "-u" in command + assert "https://example.com" in command + assert "-silent" in command + + +def test_crawler_command_uses_default_depth(setup_test_environment): + """ + When 'depth' is omitted, the command must use the default value from + metadata.json (2). + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("crawler", {"target": "https://example.com"}) + + assert command is not None + assert "-depth" in command + depth_idx = command.index("-depth") + assert command[depth_idx + 1] == "2", ( + f"Default depth must be '2'. Got: {command[depth_idx + 1]}" + ) + + +def test_crawler_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("crawler", {"target": "https://secuscan.in"}) + + assert command == ["katana", "-u", "https://secuscan.in", "-depth", "2", "-silent"], ( + f"Command template drift detected. Got: {command}" + ) + + +def test_crawler_command_respects_explicit_depth(setup_test_environment): + """When 'depth' is explicitly provided, it must override the default.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("crawler", {"target": "https://example.com", "depth": 5}) + + assert command is not None + depth_idx = command.index("-depth") + assert command[depth_idx + 1] == "5", ( + f"Explicit depth=5 must override default. Got: {command[depth_idx + 1]}" + ) + + +def test_crawler_drops_target_token_when_absent(setup_test_environment): + """ + When the 'target' field is omitted, the renderer drops the unresolved + {target} token rather than emitting an empty value or literal placeholder. + The default depth scaffold is preserved. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + rendered = manager.build_command("crawler", {}) + + assert rendered is not None + assert not any("{" in token for token in rendered), "Unresolved placeholder leaked" + assert rendered == ["katana", "-u", "-depth", "2", "-silent"] + + populated = manager.build_command("crawler", {"target": "https://example.com"}) + assert "https://example.com" in populated + assert len(populated) == len(rendered) + 1 + + +def test_crawler_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load crawler from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("crawler") + assert plugin is not None + assert plugin.id == "crawler" + assert plugin.name == "Crawler" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_CRAWLER_OUTPUT_FIXTURE = ( + "https://example.com/\n" + "https://example.com/about\n" + "https://example.com/admin [found]\n" + "https://example.com/login?redirect=http://evil.com [warning] [detected]\n" + "https://example.com/api/v1/users [exposed]\n" + "https://example.com/internal/debug [critical] [injection]\n" +) + + +def test_crawler_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_CRAWLER_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_crawler_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_CRAWLER_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_crawler_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_CRAWLER_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding, f"Finding missing key: {key}" + + +def test_crawler_parser_critical_and_injection_raise_to_high(): + """Lines containing 'critical' or 'injection' must be classified as 'high' severity.""" + result = parse(_CRAWLER_OUTPUT_FIXTURE) + high_findings = [ + f for f in result["findings"] + if "critical" in f["description"].lower() or "injection" in f["description"].lower() + ] + assert high_findings, "Expected at least one high-severity finding" + for finding in high_findings: + assert finding["severity"] == "high" + + +def test_crawler_parser_exposed_or_found_is_at_least_low(): + """Lines containing 'exposed', 'found', or 'detected' must be at least 'low' severity.""" + result = parse(_CRAWLER_OUTPUT_FIXTURE) + flagged = [ + f for f in result["findings"] + if any(kw in f["description"].lower() for kw in ("exposed", "found", "detected")) + ] + assert flagged, "Expected at least one low-severity finding from flagged keywords" + for finding in flagged: + assert finding["severity"] in ("low", "high") + + +def test_crawler_parser_items_list_matches_non_empty_lines(): + """items must contain each non-empty line from the output.""" + result = parse(_CRAWLER_OUTPUT_FIXTURE) + expected_lines = [l.strip() for l in _CRAWLER_OUTPUT_FIXTURE.splitlines() if l.strip()] + assert result["items"] == expected_lines + + +def test_crawler_parser_empty_output(): + """Parser must handle empty input without raising and return empty findings.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_crawler_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw must match the original output line.""" + single_line = "https://example.com/admin [found]\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw"] == "https://example.com/admin [found]" diff --git a/testing/backend/test_dir_discovery_plugin.py b/testing/backend/test_dir_discovery_plugin.py new file mode 100644 index 000000000..8430b4949 --- /dev/null +++ b/testing/backend/test_dir_discovery_plugin.py @@ -0,0 +1,156 @@ +""" +Wordlist guidance and safe-default contract tests for the dir_discovery plugin. + +These tests load the real plugins/dir_discovery/metadata.json, validate it through +the project PluginMetadataValidator, and assert the wordlist field's safe default +and source guidance both directly and as resolved by the real PluginManager (the +path the executor uses to turn a wordlist alias into an on-disk file). + +dir_discovery brute-forces web paths with ffuf and is therefore ``intrusive``. +Only ``small.txt`` ships with SecuScan; ``medium``/``large`` must be installed from +SecLists. The field default must therefore be the bundled ``small`` list so an +out-of-the-box scan resolves to a real wordlist and stays fast and low-traffic. + +Related to issue #855: Add safer default wordlist guidance for dir_discovery +""" + +import asyncio +import json +import os +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.config import settings +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager + +PLUGIN_DIR = REPO_ROOT / "plugins" / "dir_discovery" +PLUGINS_DIR = REPO_ROOT / "plugins" +WORDLISTS_DIR = REPO_ROOT / "wordlists" + + +def _load_metadata() -> dict: + return json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + + +def _wordlist_field(metadata: dict) -> dict: + return next(f for f in metadata["fields"] if f["id"] == "wordlist") + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_dir_discovery_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_dir_discovery_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + assert isinstance(_load_metadata(), dict) + + +def test_dir_discovery_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + assert _load_metadata()["id"] == "dir_discovery" + + +def test_dir_discovery_passes_validator(): + """The full PluginMetadataValidator must accept the plugin without errors.""" + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_dir_discovery_is_intrusive_and_consented(): + """dir_discovery brute-forces paths, so it must stay intrusive + consent-gated.""" + safety = _load_metadata()["safety"] + assert safety["level"] == "intrusive" + assert safety["requires_consent"] is True + + +# --------------------------------------------------------------------------- +# Wordlist safe-default + source guidance (issue #855) +# --------------------------------------------------------------------------- + + +def test_wordlist_options_are_small_medium_large(): + """The wordlist field must expose exactly the small/medium/large aliases.""" + field = _wordlist_field(_load_metadata()) + assert [opt["value"] for opt in field["options"]] == ["small", "medium", "large"] + + +def test_wordlist_default_is_bundled_small(): + """ + The safe default must be the bundled 'small' list. 'medium'/'large' are not + shipped, so defaulting to them would resolve to a missing file and fail. + """ + field = _wordlist_field(_load_metadata()) + assert field["default"] == "small" + assert field["default"] in {opt["value"] for opt in field["options"]} + + +def test_wordlist_help_explains_source_and_default(): + """Help text must name the expected source and the bundled-default behavior.""" + help_text = _wordlist_field(_load_metadata())["help"].lower() + assert "seclists" in help_text + assert "wordlists/readme.md" in help_text + assert "small" in help_text + + +def test_command_template_consumes_wordlist_field(): + """The wordlist field must actually feed ffuf's -w argument.""" + assert "{wordlist}" in _load_metadata()["command_template"] + + +# --------------------------------------------------------------------------- +# Bundled asset + resolution behavior +# --------------------------------------------------------------------------- + + +def test_bundled_small_wordlist_exists_and_is_nonempty(): + """small.txt must be committed and contain at least one path entry.""" + small = WORDLISTS_DIR / "small.txt" + assert small.exists(), "wordlists/small.txt must be bundled" + entries = [ln for ln in small.read_text(encoding="utf-8").splitlines() if ln.strip()] + assert entries, "wordlists/small.txt must contain at least one entry" + + +def test_default_wordlist_resolves_to_real_bundled_file(monkeypatch): + """ + The default alias ('small') must resolve through the real PluginManager to an + existing file inside the wordlists directory — proving the safe default is + backed by a shipped wordlist, not a missing external one. Point wordlists_dir + at the directory that holds the bundled list so the check is deterministic. + """ + monkeypatch.setattr(settings, "wordlists_dir", str(WORDLISTS_DIR)) + manager = PluginManager(str(PLUGINS_DIR)) + default_value = _wordlist_field(_load_metadata())["default"] + + resolved = manager._resolve_wordlist_path(default_value) + + assert os.path.exists(resolved), f"default wordlist {default_value!r} must resolve to a real file" + assert os.path.basename(resolved) == "small.txt" + resolved_parent = Path(resolved).resolve().parent + assert resolved_parent == WORDLISTS_DIR.resolve() + + +def test_dir_discovery_loads_with_refreshed_checksum(setup_test_environment): + """ + PluginManager must load dir_discovery, which only succeeds if the metadata + checksum was refreshed after the edit (integrity check rejects mismatches). + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("dir_discovery") + assert plugin is not None + assert plugin.id == "dir_discovery" diff --git a/testing/backend/test_dns_enum_plugin.py b/testing/backend/test_dns_enum_plugin.py new file mode 100644 index 000000000..2e6f24fd8 --- /dev/null +++ b/testing/backend/test_dns_enum_plugin.py @@ -0,0 +1,227 @@ +""" +Contract tests for the dns_enum (DNS Reconnaissance) plugin. + +These tests load the real plugins/dns_enum/metadata.json, validate it through +the project PluginMetadataValidator, render commands through the real +PluginManager, and exercise the real parser.py parse() function. + +The focus is the example/guidance contract added for issue #856 +("Add metadata examples for dns_enum"): the field help, long_description and +presets must document common domain targets and the output expectations +(grouped DNS records, and a critical finding when a zone transfer succeeds). +If that guidance, the command template, or the parser drift, these tests fail. + +Related to issue #856. +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.dns_enum.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "dns_enum" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +def _metadata() -> dict: + return json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_dns_enum_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_dns_enum_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + assert isinstance(_metadata(), dict) + + +def test_dns_enum_passes_validator(): + """The full PluginMetadataValidator must accept the plugin without errors.""" + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_dns_enum_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + assert _metadata()["id"] == "dns_enum" + + +def test_dns_enum_engine_is_dnsrecon(): + """Engine must be the dnsrecon CLI binary.""" + data = _metadata() + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "dnsrecon" + + +def test_dns_enum_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + assert _metadata()["output"]["parser"] == "custom" + + +def test_dns_enum_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +def test_dns_enum_has_required_target_field(): + """Plugin must declare a required 'target' field for the domain.""" + fields = {f["id"]: f for f in _metadata()["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +# --------------------------------------------------------------------------- +# Example / guidance contract (issue #856) +# --------------------------------------------------------------------------- + + +def test_dns_enum_target_help_documents_example_domain(): + """The target field help must show a concrete domain example and clarify + that it is a bare domain (no scheme or path).""" + target = next(f for f in _metadata()["fields"] if f["id"] == "target") + help_text = target["help"].lower() + assert "example.com" in target["placeholder"] + assert "example.com" in help_text + # Must steer operators away from pasting a URL. + assert "scheme" in help_text or "no http" in help_text + + +def test_dns_enum_type_help_documents_output_expectations(): + """The enum-type help must describe what each mode produces.""" + type_field = next(f for f in _metadata()["fields"] if f["id"] == "type") + help_text = type_field["help"].lower() + # Output expectations: record types, zone transfer, subdomain discovery. + assert "soa" in help_text and "mx" in help_text + assert "zone transfer" in help_text or "axfr" in help_text + assert "subdomain" in help_text + + +def test_dns_enum_long_description_documents_targets_and_output(): + """long_description must cover common targets and output expectations, + and preserve the authorized-use framing.""" + long_desc = _metadata()["long_description"].lower() + assert "example.com" in long_desc + assert "record" in long_desc + assert "critical" in long_desc and ( + "zone transfer" in long_desc or "axfr" in long_desc + ) + assert "authorized" in long_desc + + +def test_dns_enum_presets_cover_every_enum_mode(): + """Presets must give a one-click example for each declared enum type, and + every preset value must be a real option value.""" + data = _metadata() + type_field = next(f for f in data["fields"] if f["id"] == "type") + option_values = {opt["value"] for opt in type_field["options"]} + + presets = data["presets"] + preset_types = {preset.get("type") for preset in presets.values()} + + assert {"standard", "zone_transfer", "subdomain_bruteforce"} <= set(presets) + # Each enum option is represented by at least one preset... + assert option_values <= preset_types + # ...and no preset references an undeclared option value. + assert preset_types <= option_values + + +# --------------------------------------------------------------------------- +# Command rendering tests via the real PluginManager +# --------------------------------------------------------------------------- + + +def test_dns_enum_command_renders_with_default_type(setup_test_environment): + """With only a target, the default enum type ('std') is applied.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("dns_enum", {"target": "example.com"}) + + assert command == ["dnsrecon", "-d", "example.com", "-t", "std"], ( + f"Command template drift detected. Got: {command}" + ) + + +def test_dns_enum_command_renders_explicit_zone_transfer(setup_test_environment): + """An explicit AXFR selection must flow through to '-t axfr'.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + "dns_enum", {"target": "example.com", "type": "axfr"} + ) + + assert command == ["dnsrecon", "-d", "example.com", "-t", "axfr"] + + +def test_dns_enum_rejects_unknown_enum_type(setup_test_environment): + """A type outside the declared option set must be rejected, not rendered.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + with pytest.raises(ValueError): + manager.build_command( + "dns_enum", {"target": "example.com", "type": "not-a-real-mode"} + ) + + +def test_dns_enum_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must load dns_enum (this also verifies the checksum).""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("dns_enum") + assert plugin is not None + assert plugin.id == "dns_enum" + assert plugin.name == "DNS Reconnaissance" + + +# --------------------------------------------------------------------------- +# Parser checks that back the documented output expectations +# --------------------------------------------------------------------------- + + +def test_dns_enum_standard_output_groups_records_per_host(): + """The metadata promises records 'grouped per host' — verify the parser + collapses repeated values into one finding per (type, host).""" + output = "\n".join( + [ + "[*] NS adi.ns.cloudflare.com 173.245.58.56", + "[*] NS adi.ns.cloudflare.com 172.64.32.56", + "[*] MX route1.mx.cloudflare.net 162.159.205.11", + "[*] A example.com 93.184.216.34", + ] + ) + result = parse(output) + + assert result["count"] == 4 # raw record values + assert len(result["findings"]) == 3 # grouped per (type, host) + ns = next(f for f in result["findings"] if f["title"].startswith("DNS NS Record")) + assert ns["metadata"]["record_count"] == 2 + + +def test_dns_enum_zone_transfer_success_raises_critical_finding(): + """The metadata promises a critical finding when AXFR succeeds — verify it.""" + result = parse("[*] A example.com 93.184.216.34\nZone Transfer Successful") + + critical = [f for f in result["findings"] if f["severity"] == "critical"] + assert critical, "Expected a critical finding for a successful zone transfer" + assert "Zone Transfer" in critical[0]["title"] diff --git a/testing/backend/test_dnsx_plugin.py b/testing/backend/test_dnsx_plugin.py new file mode 100644 index 000000000..ba8e5f8aa --- /dev/null +++ b/testing/backend/test_dnsx_plugin.py @@ -0,0 +1,232 @@ +""" +Contract and parser tests for the dnsx plugin. + +These tests load the real plugins/dnsx/metadata.json, validate it +through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #495: Add parser and contract coverage for plugin `dnsx` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.dnsx.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "dnsx" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_dnsx_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_dnsx_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_dnsx_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_dnsx_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "dnsx" + + +def test_dnsx_engine_is_dnsx(): + """Engine binary must be 'dnsx' for DNS resolution.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "dnsx" + + +def test_dnsx_has_required_target_field(): + """Plugin must declare a required 'target' field for domain.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_dnsx_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_dnsx_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_dnsx_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct dnsx command for a domain. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("dnsx", {"target": "secuscan.in"}) + + assert command is not None, "build_command returned None for valid inputs" + assert command[0] == "dnsx" + assert "-d" in command + assert "secuscan.in" in command + assert "-silent" in command + + +def test_dnsx_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("dnsx", {"target": "secuscan.in"}) + + assert command == [ + "dnsx", + "-d", + "secuscan.in", + "-silent", + ], f"Command template drift detected. Got: {command}" + + +def test_dnsx_drops_target_token_when_absent(setup_test_environment): + """ + When the 'target' field is omitted, the renderer drops the unresolved + {target} token rather than emitting an empty value or literal placeholder. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + rendered = manager.build_command("dnsx", {}) + + assert rendered is not None + assert not any("{" in token for token in rendered), "Unresolved placeholder leaked" + assert rendered == ["dnsx", "-d", "-silent"] + + populated = manager.build_command("dnsx", {"target": "secuscan.in"}) + assert "secuscan.in" in populated + assert len(populated) == len(rendered) + 1 + + +def test_dnsx_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load dnsx from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("dnsx") + assert plugin is not None + assert plugin.id == "dnsx" + assert plugin.name == "dnsx" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_DNSX_OUTPUT_FIXTURE = ( + "secuscan.in\n" + "api.secuscan.in [alive]\n" + "dev.secuscan.in\n" + "admin.secuscan.in [exposed]\n" + "staging.secuscan.in [found]\n" +) + + +def test_dnsx_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_DNSX_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_dnsx_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_DNSX_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_dnsx_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_DNSX_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ( + "title", + "category", + "severity", + "description", + "remediation", + "metadata", + ): + assert key in finding, f"Finding missing key: {key}" + + +def test_dnsx_parser_severity_classification(): + """Lines with keywords must be 'low' severity, others 'info'.""" + result = parse(_DNSX_OUTPUT_FIXTURE) + findings = result["findings"] + assert len(findings) == 5 + + # "secuscan.in" -> info + assert findings[0]["severity"] == "info" + # "api.secuscan.in [alive]" -> low + assert findings[1]["severity"] == "low" + # "dev.secuscan.in" -> info + assert findings[2]["severity"] == "info" + # "admin.secuscan.in [exposed]" -> low + assert findings[3]["severity"] == "low" + # "staging.secuscan.in [found]" -> low + assert findings[4]["severity"] == "low" + + +def test_dnsx_parser_empty_output(): + """Parser must handle empty input and return empty findings without raising.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_dnsx_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw_line must match the original output line.""" + single_line = "sub.secuscan.in [exposed]\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw_line"] == "sub.secuscan.in [exposed]" diff --git a/testing/backend/test_domain_finder_plugin.py b/testing/backend/test_domain_finder_plugin.py new file mode 100644 index 000000000..a84c352eb --- /dev/null +++ b/testing/backend/test_domain_finder_plugin.py @@ -0,0 +1,225 @@ +""" +Contract and parser tests for the domain-finder plugin. + +These tests load the real plugins/domain-finder/metadata.json, validate it +through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #496: Add parser and contract coverage for plugin `domain-finder` +""" + +import asyncio +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager + +PLUGIN_DIR = REPO_ROOT / "plugins" / "domain-finder" +PLUGINS_DIR = REPO_ROOT / "plugins" + +# Import parser from domain-finder (hyphenated directory name requires importlib) +spec = importlib.util.spec_from_file_location("domain_finder_parser", str(PLUGIN_DIR / "parser.py")) +_parser_module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(_parser_module) +parse = _parser_module.parse + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_domain_finder_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_domain_finder_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_domain_finder_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_domain_finder_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "domain-finder" + + +def test_domain_finder_engine_is_amass(): + """Engine binary must be 'amass' for domain enumeration.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "amass" + + +def test_domain_finder_has_required_target_field(): + """Plugin must declare a required 'target' field for domain.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_domain_finder_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_domain_finder_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_domain_finder_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct domain-finder command for a domain. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("domain-finder", {"target": "secuscan.in"}) + + assert command is not None, "build_command returned None for valid inputs" + assert command[0] == "amass" + assert "enum" in command + assert "-d" in command + assert "secuscan.in" in command + assert "-dir" in command + assert "/tmp/amass" in command + assert "-silent" in command + + +def test_domain_finder_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("domain-finder", {"target": "secuscan.in"}) + + assert command == [ + "amass", + "enum", + "-d", + "secuscan.in", + "-dir", + "/tmp/amass", + "-silent", + ], f"Command template drift detected. Got: {command}" + + +def test_domain_finder_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load domain-finder from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("domain-finder") + assert plugin is not None + assert plugin.id == "domain-finder" + assert plugin.name == "Domain Finder" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_DOMAIN_FINDER_OUTPUT_FIXTURE = ( + "secuscan.in\n" + "api.secuscan.in [alive]\n" + "dev.secuscan.in\n" + "admin.secuscan.in [exposed]\n" + "staging.secuscan.in [found]\n" +) + + +def test_domain_finder_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_DOMAIN_FINDER_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_domain_finder_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_DOMAIN_FINDER_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_domain_finder_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_DOMAIN_FINDER_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ( + "title", + "category", + "severity", + "description", + "remediation", + "metadata", + ): + assert key in finding, f"Finding missing key: {key}" + + +def test_domain_finder_parser_severity_classification(): + """Lines with keywords must be 'low' severity, others 'info'.""" + result = parse(_DOMAIN_FINDER_OUTPUT_FIXTURE) + findings = result["findings"] + assert len(findings) == 5 + + # "secuscan.in" -> info + assert findings[0]["severity"] == "info" + # "api.secuscan.in [alive]" -> low + assert findings[1]["severity"] == "low" + # "dev.secuscan.in" -> info + assert findings[2]["severity"] == "info" + # "admin.secuscan.in [exposed]" -> low + assert findings[3]["severity"] == "low" + # "staging.secuscan.in [found]" -> low + assert findings[4]["severity"] == "low" + + +def test_domain_finder_parser_empty_output(): + """Parser must handle empty input and return empty findings without raising.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_domain_finder_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw_line must match the original output line.""" + single_line = "sub.secuscan.in [exposed]\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw_line"] == "sub.secuscan.in [exposed]" diff --git a/testing/backend/test_fuzzer_plugin.py b/testing/backend/test_fuzzer_plugin.py new file mode 100644 index 000000000..ce0490388 --- /dev/null +++ b/testing/backend/test_fuzzer_plugin.py @@ -0,0 +1,219 @@ +""" +Contract and parser tests for the fuzzer plugin. + +These tests load the real plugins/fuzzer/metadata.json, validate it +through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Related to issue #497: Add parser and contract coverage for plugin `fuzzer` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.fuzzer.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "fuzzer" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_fuzzer_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_fuzzer_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_fuzzer_passes_validator(): + """The full PluginMetadataValidator must accept the plugin without errors.""" + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_fuzzer_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "fuzzer" + + +def test_fuzzer_engine_is_python3(): + """Engine binary must be 'python3'.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "python3" + + +def test_fuzzer_has_required_target_field(): + """Plugin must declare a required 'target' field.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_fuzzer_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_fuzzer_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +def test_fuzzer_requires_consent(): + """Fuzzer is exploit-level and must require consent.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["safety"]["requires_consent"] is True + + +def test_fuzzer_safety_level_is_exploit(): + """Safety level must be 'exploit'.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["safety"]["level"] == "exploit" + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_fuzzer_command_renders_with_target(setup_test_environment): + """PluginManager must produce a valid command for a target.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("fuzzer", {"target": "https://secuscan.in"}) + + assert command is not None, "build_command returned None for valid inputs" + assert "python3" in command + assert "https://secuscan.in" in command + + +def test_fuzzer_command_contains_target_token(setup_test_environment): + """Rendered command must contain the target value.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("fuzzer", {"target": "https://example.com"}) + assert "https://example.com" in command + + +def test_fuzzer_drops_target_token_when_absent(setup_test_environment): + """When 'target' is omitted, no unresolved placeholder must appear.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + rendered = manager.build_command("fuzzer", {}) + assert rendered is not None + assert not any("{" in token for token in rendered), "Unresolved placeholder leaked" + + +def test_fuzzer_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load fuzzer from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("fuzzer") + assert plugin is not None + assert plugin.id == "fuzzer" + assert plugin.name == "Payload Fuzzer" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_FUZZER_OUTPUT_FIXTURE = ( + "Fuzzer simulation\n" + "target=https://secuscan.in\n" + "payload_count=1000\n" + "found injection point at /search\n" + "critical: exploit successful at /admin\n" +) + + +def test_fuzzer_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_FUZZER_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_fuzzer_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_FUZZER_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_fuzzer_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_FUZZER_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding, f"Finding missing key: {key}" + + +def test_fuzzer_parser_severity_classification(): + """Lines with exploit/critical keywords must be 'high'; found/injection 'low'; others 'info'.""" + result = parse(_FUZZER_OUTPUT_FIXTURE) + findings = {f["description"]: f["severity"] for f in result["findings"]} + + assert findings["Fuzzer simulation"] == "info" + assert findings["target=https://secuscan.in"] == "info" + assert findings["payload_count=1000"] == "info" + assert findings["found injection point at /search"] == "high" + assert findings["critical: exploit successful at /admin"] == "high" + + +def test_fuzzer_parser_empty_output(): + """Parser must handle empty input and return empty findings without raising.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_fuzzer_parser_high_severity_on_critical(): + """Lines containing 'critical' must produce high severity findings.""" + result = parse("critical vulnerability detected\n") + assert result["findings"][0]["severity"] == "high" + + +def test_fuzzer_parser_low_severity_on_found(): + """Lines containing 'found' must produce low severity findings.""" + result = parse("found open endpoint\n") + assert result["findings"][0]["severity"] == "low" + + +def test_fuzzer_parser_respects_300_line_limit(): + """Parser must cap output at 300 lines.""" + big_output = "\n".join(f"line {i}" for i in range(500)) + result = parse(big_output) + assert result["count"] <= 300 + assert len(result["items"]) <= 300 diff --git a/testing/backend/test_google_dorking_plugin.py b/testing/backend/test_google_dorking_plugin.py new file mode 100644 index 000000000..e6d872a34 --- /dev/null +++ b/testing/backend/test_google_dorking_plugin.py @@ -0,0 +1,213 @@ +""" +Contract and parser tests for the google-dorking plugin. + +These tests load the real plugins/google-dorking/metadata.json, validate it +through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #498: Add parser and contract coverage for plugin `google-dorking` +""" + +import asyncio +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager + +# --------------------------------------------------------------------------- +# Load parser from hyphenated directory name +# --------------------------------------------------------------------------- +_spec = importlib.util.spec_from_file_location( + "google_dorking_parser", + REPO_ROOT / "plugins" / "google-dorking" / "parser.py" +) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +parse = _mod.parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "google-dorking" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_google_dorking_metadata_file_exists(): + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_google_dorking_metadata_is_valid_json(): + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_google_dorking_passes_validator(): + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_google_dorking_metadata_id_matches_directory(): + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "google-dorking" + + +def test_google_dorking_engine_is_python3(): + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "python3" + + +def test_google_dorking_has_required_target_field(): + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields + assert fields["target"]["required"] is True + + +def test_google_dorking_output_parser_is_custom(): + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_google_dorking_parser_file_exists(): + assert (PLUGIN_DIR / "parser.py").exists() + + +def test_google_dorking_does_not_require_consent(): + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["safety"]["requires_consent"] is False + + +def test_google_dorking_category_is_recon(): + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["category"] == "recon" + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_google_dorking_loaded_by_plugin_manager(setup_test_environment): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + plugin = manager.get_plugin("google-dorking") + assert plugin is not None + assert plugin.id == "google-dorking" + assert plugin.name == "Google Hacking" + + +def test_google_dorking_command_renders_with_target(setup_test_environment): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + command = manager.build_command("google-dorking", {"target": "secuscan.in"}) + assert command is not None + assert command[0] == "python3" + assert "secuscan.in" in " ".join(command) + + +def test_google_dorking_command_contains_dork_queries(setup_test_environment): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + command = manager.build_command("google-dorking", {"target": "secuscan.in"}) + full_command = " ".join(command) + assert "site:" in full_command + assert "inurl:admin" in full_command + assert "filetype:sql" in full_command + assert "intitle:index.of" in full_command + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_GOOGLE_DORKING_OUTPUT_FIXTURE = ( + "site:secuscan.in\n" + "inurl:admin site:secuscan.in\n" + "filetype:sql site:secuscan.in\n" + "intitle:index.of secuscan.in\n" + "exposed admin panel found at secuscan.in/admin\n" + "open directory detected\n" +) + + +def test_google_dorking_parser_returns_required_keys(): + result = parse(_GOOGLE_DORKING_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_google_dorking_parser_count_matches_findings(): + result = parse(_GOOGLE_DORKING_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_google_dorking_parser_finding_has_required_keys(): + result = parse(_GOOGLE_DORKING_OUTPUT_FIXTURE) + assert result["findings"] + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding + + +def test_google_dorking_parser_finding_title_is_stable(): + result = parse(_GOOGLE_DORKING_OUTPUT_FIXTURE) + for finding in result["findings"]: + assert finding["title"] == "Google Hacking Observation" + + +def test_google_dorking_parser_severity_classification(): + result = parse(_GOOGLE_DORKING_OUTPUT_FIXTURE) + findings = result["findings"] + assert len(findings) == 6 + assert findings[0]["severity"] == "info" + assert findings[1]["severity"] == "info" + assert findings[2]["severity"] == "info" + assert findings[3]["severity"] == "info" + assert findings[4]["severity"] == "low" + assert findings[5]["severity"] == "low" + + +def test_google_dorking_parser_empty_output(): + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_google_dorking_parser_preserves_raw_line_in_metadata(): + single_line = "exposed login page found at secuscan.in/admin\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw_line"] == "exposed login page found at secuscan.in/admin" + + +def test_google_dorking_parser_items_matches_lines(): + result = parse(_GOOGLE_DORKING_OUTPUT_FIXTURE) + expected = [line.strip() for line in _GOOGLE_DORKING_OUTPUT_FIXTURE.splitlines() if line.strip()] + assert result["items"] == expected + + +def test_google_dorking_parser_respects_200_line_limit(): + large_input = "\n".join(f"site:example.com/page{i}" for i in range(300)) + result = parse(large_input) + assert result["count"] <= 200 + assert len(result["items"]) <= 200 \ No newline at end of file diff --git a/testing/backend/test_http_inspector_plugin.py b/testing/backend/test_http_inspector_plugin.py new file mode 100644 index 000000000..1e58bd2aa --- /dev/null +++ b/testing/backend/test_http_inspector_plugin.py @@ -0,0 +1,135 @@ +""" +Capability inventory and contract tests for the http_inspector plugin. + +These tests load the real plugins/http_inspector/metadata.json, validate it +through the project PluginMetadataValidator, and assert the declared capability +inventory both directly and as surfaced by the real PluginManager (the path the +UI uses for capability-based grouping). + +http_inspector performs safe, read-only HTTP requests, so the only capability it +exercises is outbound ``network``. Declaring it explicitly keeps UI grouping +predictable instead of relying on the safety-level implied fallback. + +Related to issue #854: Add capability inventory notes for http_inspector +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.capabilities import ( + effective_capabilities, + validate_capability_list, + _SAFETY_LEVEL_IMPLIED, +) +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager + +PLUGIN_DIR = REPO_ROOT / "plugins" / "http_inspector" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +def _load_metadata() -> dict: + return json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_http_inspector_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_http_inspector_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + assert isinstance(_load_metadata(), dict) + + +def test_http_inspector_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + assert _load_metadata()["id"] == "http_inspector" + + +def test_http_inspector_passes_validator(): + """The full PluginMetadataValidator must accept the plugin without errors.""" + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_http_inspector_is_safe_level(): + """http_inspector is a read-only HTTP tool and must stay at safety level 'safe'.""" + assert _load_metadata()["safety"]["level"] == "safe" + + +# --------------------------------------------------------------------------- +# Capability inventory tests (issue #854) +# --------------------------------------------------------------------------- + + +def test_http_inspector_declares_capabilities_explicitly(): + """Metadata must declare an explicit capabilities list (not rely on the fallback).""" + data = _load_metadata() + assert "capabilities" in data, "http_inspector must declare a capabilities list" + assert data["capabilities"] == ["network"] + + +def test_http_inspector_capabilities_are_known_tokens(): + """Declared capabilities must all be recognised capability tokens.""" + caps = _load_metadata()["capabilities"] + assert validate_capability_list(caps, "http_inspector") == ["network"] + + +def test_http_inspector_capabilities_match_implied_safe_set(): + """ + The explicit declaration must match the capability set previously implied by + the 'safe' safety level, proving this change documents existing behavior + rather than altering enforcement. + """ + data = _load_metadata() + effective = effective_capabilities( + data["capabilities"], data["safety"]["level"], "http_inspector" + ) + assert effective == {"network"} + assert effective == set(_SAFETY_LEVEL_IMPLIED["safe"]) + + +# --------------------------------------------------------------------------- +# Capability surfacing via the real PluginManager (UI grouping path) +# --------------------------------------------------------------------------- + + +def test_http_inspector_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must load http_inspector and expose its declared capabilities.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("http_inspector") + assert plugin is not None + assert plugin.id == "http_inspector" + assert plugin.capabilities == ["network"] + + +def test_http_inspector_capabilities_surface_in_listing(setup_test_environment): + """ + list_plugins() must report capabilities for http_inspector so the UI can group + it predictably. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + entry = next( + (p for p in manager.list_plugins() if p["id"] == "http_inspector"), None + ) + assert entry is not None + assert entry["capabilities"] == ["network"] diff --git a/testing/backend/test_http_request_logger_plugin.py b/testing/backend/test_http_request_logger_plugin.py new file mode 100644 index 000000000..9016e85db --- /dev/null +++ b/testing/backend/test_http_request_logger_plugin.py @@ -0,0 +1,253 @@ +""" +Contract and parser tests for the http_request_logger plugin. + +These tests load the real plugins/http_request_logger/metadata.json, validate it +through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #499: Add parser and contract coverage for plugin `http_request_logger` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.http_request_logger.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "http_request_logger" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_http_request_logger_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_http_request_logger_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_http_request_logger_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_http_request_logger_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "http_request_logger" + + +def test_http_request_logger_engine_is_httpx(): + """Engine binary must be 'httpx' for HTTP logging.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "httpx" + + +def test_http_request_logger_has_required_target_field(): + """Plugin must declare a required 'target' field for URL.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_http_request_logger_target_has_url_validation(): + """Target field must have URL pattern validation.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "validation" in fields["target"] + assert "pattern" in fields["target"]["validation"] + + +def test_http_request_logger_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_http_request_logger_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +def test_http_request_logger_requires_consent(): + """HTTP request logging is intrusive and requires consent.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["safety"]["requires_consent"] is True + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_http_request_logger_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct httpx command for a target URL. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("http_request_logger", {"target": "https://secuscan.in"}) + + assert command is not None, "build_command returned None for valid inputs" + assert command[0] == "httpx" + assert "-u" in command + assert "https://secuscan.in" in command + assert "-status-code" in command + assert "-title" in command + assert "-web-server" in command + assert "-silent" in command + + +def test_http_request_logger_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("http_request_logger", {"target": "https://secuscan.in"}) + + assert command == [ + "httpx", + "-u", + "https://secuscan.in", + "-status-code", + "-title", + "-web-server", + "-silent", + ], f"Command template drift detected. Got: {command}" + + +def test_http_request_logger_drops_target_token_when_absent(setup_test_environment): + """ + When the 'target' field is omitted, the renderer drops the unresolved + {target} token rather than emitting an empty value or literal placeholder. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + rendered = manager.build_command("http_request_logger", {}) + + assert rendered is not None + assert not any("{" in token for token in rendered), "Unresolved placeholder leaked" + assert "httpx" in rendered + assert "-status-code" in rendered + + populated = manager.build_command("http_request_logger", {"target": "https://secuscan.in"}) + assert "https://secuscan.in" in populated + assert len(populated) > len(rendered) + + +def test_http_request_logger_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load http_request_logger from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("http_request_logger") + assert plugin is not None + assert plugin.id == "http_request_logger" + assert plugin.name == "HTTP Request Logger" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_HTTP_REQUEST_LOGGER_OUTPUT_FIXTURE = ( + "[200] https://secuscan.in\n" + "[200] https://secuscan.in/api\n" + "[403] https://secuscan.in/admin - Exposed\n" + "[500] https://api.secuscan.in - Critical Error\n" + "[302] https://secuscan.in/redirect - Open redirect detected\n" +) + + +def test_http_request_logger_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_HTTP_REQUEST_LOGGER_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_http_request_logger_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_HTTP_REQUEST_LOGGER_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_http_request_logger_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_HTTP_REQUEST_LOGGER_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ( + "title", + "category", + "severity", + "description", + "remediation", + "metadata", + ): + assert key in finding, f"Finding missing key: {key}" + + +def test_http_request_logger_parser_severity_classification(): + """Severity must be classified based on keywords: info, low (found/warning), high (critical/exploit).""" + result = parse(_HTTP_REQUEST_LOGGER_OUTPUT_FIXTURE) + findings = result["findings"] + assert len(findings) == 5 + + # "[200] https://secuscan.in" -> info + assert findings[0]["severity"] == "info" + # "[200] https://secuscan.in/api" -> info + assert findings[1]["severity"] == "info" + # "[403] ... Exposed" -> low + assert findings[2]["severity"] == "low" + # "[500] ... Critical Error" -> high + assert findings[3]["severity"] == "high" + # "[302] ... Open redirect detected" -> low + assert findings[4]["severity"] == "low" + + +def test_http_request_logger_parser_empty_output(): + """Parser must handle empty input and return empty findings without raising.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_http_request_logger_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw must match the original output line.""" + single_line = "[200] https://secuscan.in/api - Vulnerable endpoint\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw"] == "[200] https://secuscan.in/api - Vulnerable endpoint" diff --git a/testing/backend/test_iac_scanner_plugin.py b/testing/backend/test_iac_scanner_plugin.py new file mode 100644 index 000000000..47b9ab10b --- /dev/null +++ b/testing/backend/test_iac_scanner_plugin.py @@ -0,0 +1,230 @@ +""" +Contract and parser tests for the iac_scanner plugin. + +These tests load the real plugins/iac_scanner/metadata.json, validate it +through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #500: Add parser and contract coverage for plugin `iac_scanner` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.iac_scanner.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "iac_scanner" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_iac_scanner_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_iac_scanner_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_iac_scanner_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_iac_scanner_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "iac_scanner" + + +def test_iac_scanner_engine_is_python3(): + """Engine binary must be 'python3' for IaC scanning.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "python3" + + +def test_iac_scanner_has_required_target_field(): + """Plugin must declare a required 'target' field for IaC directory.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_iac_scanner_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_iac_scanner_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +def test_iac_scanner_does_not_require_consent(): + """IaC scanning is safe analysis and does not require consent.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["safety"]["requires_consent"] is False + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_iac_scanner_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct iac_scanner command for a directory. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("iac_scanner", {"target": "/path/to/iac"}) + + assert command is not None, "build_command returned None for valid inputs" + assert command[0] == "python3" + assert "-c" in command + assert "/path/to/iac" in command + + +def test_iac_scanner_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("iac_scanner", {"target": "/tmp/iac_files"}) + + assert command[0] == "python3" + assert command[1] == "-c" + assert "/tmp/iac_files" in command + + +def test_iac_scanner_renders_without_target_field(setup_test_environment): + """ + When target field is omitted, PluginManager renders the command as-is. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("iac_scanner", {}) + + assert command is not None + assert len(command) >= 2 + assert command[0] == "python3" + assert command[1] == "-c" + + +def test_iac_scanner_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load iac_scanner from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("iac_scanner") + assert plugin is not None + assert plugin.id == "iac_scanner" + assert plugin.name == "IaC Scanner (Checkov)" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_IAC_SCANNER_OUTPUT_FIXTURE = ( + "terraform/main.tf: Resource configuration found\n" + "terraform/security.tf: Open security group detected\n" + "cloudformation/vpc.yaml: Critical network exposure found\n" + "infrastructure/rds.json: Vulnerable database configuration detected\n" + "ansible/playbook.yml: Warning: unencrypted secrets in file\n" +) + + +def test_iac_scanner_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_IAC_SCANNER_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_iac_scanner_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_IAC_SCANNER_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_iac_scanner_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_IAC_SCANNER_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ( + "title", + "category", + "severity", + "description", + "remediation", + "metadata", + ): + assert key in finding, f"Finding missing key: {key}" + + +def test_iac_scanner_parser_severity_classification(): + """Severity must be classified based on keywords: info, low (found/warning), high (critical/exploit).""" + result = parse(_IAC_SCANNER_OUTPUT_FIXTURE) + findings = result["findings"] + assert len(findings) == 5 + + # "Resource configuration found" -> low + assert findings[0]["severity"] == "low" + # "Open security group detected" -> low + assert findings[1]["severity"] == "low" + # "Critical network exposure found" -> high + assert findings[2]["severity"] == "high" + # "Vulnerable database configuration detected" -> low + assert findings[3]["severity"] == "low" + # "Warning: unencrypted secrets" -> low + assert findings[4]["severity"] == "low" + + +def test_iac_scanner_parser_empty_output(): + """Parser must handle empty input and return empty findings without raising.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_iac_scanner_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw must match the original output line.""" + single_line = "terraform/main.tf: Critical infrastructure vulnerability detected\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw"] == "terraform/main.tf: Critical infrastructure vulnerability detected" diff --git a/testing/backend/test_katana_plugin.py b/testing/backend/test_katana_plugin.py new file mode 100644 index 000000000..c826c1d9e --- /dev/null +++ b/testing/backend/test_katana_plugin.py @@ -0,0 +1,238 @@ +""" +Contract and parser tests for the katana plugin. + +These tests load the real plugins/katana/metadata.json, validate it +through the project PluginMetadataValidator, render commands through the +real PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #501: Add parser and contract coverage for plugin `katana` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.katana.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "katana" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_katana_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_katana_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_katana_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_katana_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "katana" + + +def test_katana_engine_is_katana(): + """Engine binary must be 'katana'.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "katana" + + +def test_katana_has_required_target_field(): + """Plugin must declare a required 'target' field.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_katana_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_katana_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +def test_katana_requires_consent(): + """Katana crawling is intrusive and requires consent.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["safety"]["requires_consent"] is True + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_katana_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct katana command for a target. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("katana", {"target": "https://secuscan.in"}) + + assert command is not None, "build_command returned None for valid inputs" + assert command[0] == "katana" + assert "-u" in command + assert "https://secuscan.in" in command + assert "-silent" in command + + +def test_katana_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("katana", {"target": "https://secuscan.in"}) + + assert command == [ + "katana", + "-u", + "https://secuscan.in", + "-silent", + ], f"Command template drift detected. Got: {command}" + + +def test_katana_drops_target_token_when_absent(setup_test_environment): + """ + When the 'target' field is omitted, the renderer drops the unresolved + {target} token rather than emitting an empty value or literal placeholder. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + rendered = manager.build_command("katana", {}) + + assert rendered is not None + assert not any("{" in token for token in rendered), "Unresolved placeholder leaked" + assert rendered == ["katana", "-u", "-silent"] + + populated = manager.build_command("katana", {"target": "https://secuscan.in"}) + assert "https://secuscan.in" in populated + assert len(populated) == len(rendered) + 1 + + +def test_katana_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load katana from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("katana") + assert plugin is not None + assert plugin.id == "katana" + assert plugin.name == "Katana" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + +_KATANA_OUTPUT_FIXTURE = ( + "https://secuscan.in\n" + "https://secuscan.in/api\n" + "https://secuscan.in/api/exposed\n" + "https://api.secuscan.in/v1/endpoint\n" + "https://admin.secuscan.in/vulnerable\n" +) + + +def test_katana_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_KATANA_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_katana_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_KATANA_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_katana_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_KATANA_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ( + "title", + "category", + "severity", + "description", + "remediation", + "metadata", + ): + assert key in finding, f"Finding missing key: {key}" + + +def test_katana_parser_severity_classification(): + """Lines with vulnerability keywords must be 'low' severity, others 'info'.""" + result = parse(_KATANA_OUTPUT_FIXTURE) + findings = result["findings"] + assert len(findings) == 5 + + # "https://secuscan.in" -> info + assert findings[0]["severity"] == "info" + # "https://secuscan.in/api" -> info + assert findings[1]["severity"] == "info" + # "https://secuscan.in/api/exposed" -> low + assert findings[2]["severity"] == "low" + # "https://api.secuscan.in/v1/endpoint" -> info + assert findings[3]["severity"] == "info" + # "https://admin.secuscan.in/vulnerable" -> low + assert findings[4]["severity"] == "low" + + +def test_katana_parser_empty_output(): + """Parser must handle empty input and return empty findings without raising.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_katana_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw_line must match the original output line.""" + single_line = "https://secuscan.in/admin/exposed\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw_line"] == "https://secuscan.in/admin/exposed" diff --git a/testing/backend/test_kubernetes_scanner_plugin.py b/testing/backend/test_kubernetes_scanner_plugin.py new file mode 100644 index 000000000..d81343ea3 --- /dev/null +++ b/testing/backend/test_kubernetes_scanner_plugin.py @@ -0,0 +1,187 @@ +""" +Contract and parser tests for the kubernetes_scanner plugin. + +These tests load the real plugins/kubernetes_scanner/metadata.json, validate it through +the project PluginMetadataValidator, render commands through the real +PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #502: Add parser and contract coverage for plugin `kubernetes_scanner` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.kubernetes_scanner.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "kubernetes_scanner" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_kubernetes_scanner_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_kubernetes_scanner_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_kubernetes_scanner_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + + This will fail if any required field is missing, the engine type or safety + level is invalid, the command template references an undeclared field, or + the checksum field is absent or malformed. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, ( + "Plugin validation errors:\n" + + "\n".join(e.display() for e in result.errors) + ) + + +def test_kubernetes_scanner_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "kubernetes_scanner" + + +def test_kubernetes_scanner_engine_is_python3(): + """Engine binary must be 'python3' -- update this if the underlying tool changes.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "python3" + + +def test_kubernetes_scanner_has_required_target_field(): + """Plugin must declare a required 'target' field for Kubernetes cluster assessment.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_kubernetes_scanner_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_kubernetes_scanner_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_kubernetes_scanner_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct command for Kubernetes cluster assessment. + + This test will fail if command_template in metadata.json changes or a + placeholder becomes mismatched. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("kubernetes_scanner", {"target": "prod-cluster"}) + + assert command is not None, "build_command returned None for valid inputs" + assert "python3" in command + assert "-c" in command + assert "prod-cluster" in command + + +def test_kubernetes_scanner_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load kubernetes_scanner from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("kubernetes_scanner") + assert plugin is not None + assert plugin.id == "kubernetes_scanner" + assert plugin.name == "K8s Scanner" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + + +_KUBERNETES_SCANNER_OUTPUT_FIXTURE = ( + "RBAC Issue: admin role has excessive permissions\n" + "NetworkPolicy: default namespace has no ingress policies\n" + "Misconfiguration: secret stored unencrypted in etcd\n" + "RBAC Issue: service account has cluster-admin role\n" +) + + +def test_kubernetes_scanner_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_KUBERNETES_SCANNER_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_kubernetes_scanner_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_KUBERNETES_SCANNER_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_kubernetes_scanner_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_KUBERNETES_SCANNER_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding, f"Finding missing key: {key}" + + +def test_kubernetes_scanner_parser_items_list_matches_non_empty_lines(): + """items must contain each non-empty line from the output.""" + result = parse(_KUBERNETES_SCANNER_OUTPUT_FIXTURE) + expected_lines = [l.strip() for l in _KUBERNETES_SCANNER_OUTPUT_FIXTURE.splitlines() if l.strip()] + assert result["items"] == expected_lines + + +def test_kubernetes_scanner_parser_empty_output(): + """Parser must handle empty input without raising and return empty findings.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_kubernetes_scanner_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw must match the original output line.""" + single_line = "RBAC Issue: admin role has excessive permissions\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw"] == "RBAC Issue: admin role has excessive permissions" diff --git a/testing/backend/test_network_scanner_plugin.py b/testing/backend/test_network_scanner_plugin.py new file mode 100644 index 000000000..218752ccb --- /dev/null +++ b/testing/backend/test_network_scanner_plugin.py @@ -0,0 +1,203 @@ +""" +Contract and parser tests for the network_scanner plugin. + +These tests load the real plugins/network_scanner/metadata.json, validate it through +the project PluginMetadataValidator, render commands through the real +PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #503: Add parser and contract coverage for plugin `network_scanner` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.network_scanner.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "network_scanner" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_network_scanner_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_network_scanner_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_network_scanner_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + + This will fail if any required field is missing, the engine type or safety + level is invalid, the command template references an undeclared field, or + the checksum field is absent or malformed. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, ( + "Plugin validation errors:\n" + + "\n".join(e.display() for e in result.errors) + ) + + +def test_network_scanner_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "network_scanner" + + +def test_network_scanner_engine_is_nmap(): + """Engine binary must be 'nmap' -- update this if the underlying tool changes.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "nmap" + + +def test_network_scanner_has_required_target_field(): + """Plugin must declare a required 'target' field for network scanning.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_network_scanner_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_network_scanner_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_network_scanner_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct nmap command for network scanning. + + This test will fail if command_template in metadata.json changes or a + placeholder becomes mismatched. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("network_scanner", {"target": "192.168.1.1"}) + + assert command is not None, "build_command returned None for valid inputs" + assert "nmap" in command + assert "--top-ports" in command + assert "192.168.1.1" in command + + +def test_network_scanner_command_uses_default_top_ports(setup_test_environment): + """When top_ports is omitted, the command must use the default value from metadata.json.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("network_scanner", {"target": "example.com"}) + + assert command is not None + assert "--top-ports" in command + ports_idx = command.index("--top-ports") + assert command[ports_idx + 1] == "1000", ( + f"Default top ports must be '1000'. Got: {command[ports_idx + 1]}" + ) + + +def test_network_scanner_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load network_scanner from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("network_scanner") + assert plugin is not None + assert plugin.id == "network_scanner" + assert plugin.name == "Network Scanner" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + + +_NETWORK_SCANNER_OUTPUT_FIXTURE = ( + "PORT STATE SERVICE\n" + "22/tcp open ssh\n" + "80/tcp open http\n" + "443/tcp open https\n" + "3306/tcp open mysql\n" +) + + +def test_network_scanner_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_NETWORK_SCANNER_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_network_scanner_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_NETWORK_SCANNER_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_network_scanner_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_NETWORK_SCANNER_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding, f"Finding missing key: {key}" + + +def test_network_scanner_parser_items_list_matches_non_empty_lines(): + """items must contain each non-empty line from the output.""" + result = parse(_NETWORK_SCANNER_OUTPUT_FIXTURE) + expected_lines = [l.strip() for l in _NETWORK_SCANNER_OUTPUT_FIXTURE.splitlines() if l.strip()] + assert result["items"] == expected_lines + + +def test_network_scanner_parser_empty_output(): + """Parser must handle empty input without raising and return empty findings.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_network_scanner_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw must match the original output line.""" + single_line = "22/tcp open ssh\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw"] == "22/tcp open ssh" diff --git a/testing/backend/test_password_auditor_plugin.py b/testing/backend/test_password_auditor_plugin.py new file mode 100644 index 000000000..df3f62303 --- /dev/null +++ b/testing/backend/test_password_auditor_plugin.py @@ -0,0 +1,187 @@ +""" +Contract and parser tests for the password_auditor plugin. + +These tests load the real plugins/password_auditor/metadata.json, validate it through +the project PluginMetadataValidator, render commands through the real +PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #504: Add parser and contract coverage for plugin `password_auditor` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.password_auditor.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "password_auditor" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_password_auditor_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_password_auditor_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_password_auditor_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + + This will fail if any required field is missing, the engine type or safety + level is invalid, the command template references an undeclared field, or + the checksum field is absent or malformed. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, ( + "Plugin validation errors:\n" + + "\n".join(e.display() for e in result.errors) + ) + + +def test_password_auditor_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "password_auditor" + + +def test_password_auditor_engine_is_python3(): + """Engine binary must be 'python3' -- update this if the underlying tool changes.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "python3" + + +def test_password_auditor_has_required_target_field(): + """Plugin must declare a required 'target' field for credential auditing.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_password_auditor_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_password_auditor_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_password_auditor_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct command for password auditing. + + This test will fail if command_template in metadata.json changes or a + placeholder becomes mismatched. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("password_auditor", {"target": "example.com"}) + + assert command is not None, "build_command returned None for valid inputs" + assert "python3" in command + assert "-c" in command + assert "example.com" in command + + +def test_password_auditor_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load password_auditor from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("password_auditor") + assert plugin is not None + assert plugin.id == "password_auditor" + assert plugin.name == "Password Auditor" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + + +_PASSWORD_AUDITOR_OUTPUT_FIXTURE = ( + "admin:password123\n" + "root:letmein\n" + "user:qwerty\n" + "test:123456\n" +) + + +def test_password_auditor_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_PASSWORD_AUDITOR_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_password_auditor_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_PASSWORD_AUDITOR_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_password_auditor_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_PASSWORD_AUDITOR_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding, f"Finding missing key: {key}" + + +def test_password_auditor_parser_items_list_matches_non_empty_lines(): + """items must contain each non-empty line from the output.""" + result = parse(_PASSWORD_AUDITOR_OUTPUT_FIXTURE) + expected_lines = [l.strip() for l in _PASSWORD_AUDITOR_OUTPUT_FIXTURE.splitlines() if l.strip()] + assert result["items"] == expected_lines + + +def test_password_auditor_parser_empty_output(): + """Parser must handle empty input without raising and return empty findings.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_password_auditor_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw must match the original output line.""" + single_line = "admin:password123\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw"] == "admin:password123" diff --git a/testing/backend/test_people_email_discovery_plugin.py b/testing/backend/test_people_email_discovery_plugin.py new file mode 100644 index 000000000..89d6eb31c --- /dev/null +++ b/testing/backend/test_people_email_discovery_plugin.py @@ -0,0 +1,201 @@ +""" +Contract and parser tests for the people-email-discovery plugin. + +These tests load the real plugins/people-email-discovery/metadata.json, validate it through +the project PluginMetadataValidator, render commands through the real +PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #505: Add parser and contract coverage for plugin `people-email-discovery` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.people_email_discovery.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "people-email-discovery" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_people_email_discovery_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_people_email_discovery_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_people_email_discovery_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + + This will fail if any required field is missing, the engine type or safety + level is invalid, the command template references an undeclared field, or + the checksum field is absent or malformed. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, ( + "Plugin validation errors:\n" + + "\n".join(e.display() for e in result.errors) + ) + + +def test_people_email_discovery_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "people-email-discovery" + + +def test_people_email_discovery_engine_is_theHarvester(): + """Engine binary must be 'theHarvester' -- update this if the underlying tool changes.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "theHarvester" + + +def test_people_email_discovery_has_required_target_field(): + """Plugin must declare a required 'target' field for the domain to enumerate.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_people_email_discovery_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_people_email_discovery_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_people_email_discovery_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct theHarvester command for domain enumeration. + + This test will fail if command_template in metadata.json changes or a + placeholder becomes mismatched. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("people-email-discovery", {"target": "example.com"}) + + assert command is not None, "build_command returned None for valid inputs" + assert "theHarvester" in command + assert "-d" in command + assert "example.com" in command + assert "-b" in command + assert "all" in command + + +def test_people_email_discovery_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("people-email-discovery", {"target": "secuscan.in"}) + + assert command == ["theHarvester", "-d", "secuscan.in", "-b", "all"], ( + f"Command template drift detected. Got: {command}" + ) + + +def test_people_email_discovery_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load people-email-discovery from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("people-email-discovery") + assert plugin is not None + assert plugin.id == "people-email-discovery" + assert plugin.name == "People Hunter" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + + +_PEOPLE_EMAIL_DISCOVERY_OUTPUT_FIXTURE = ( + "admin@example.com\n" + "info@example.com\n" + "support@example.com\n" + "test@example.com\n" +) + + +def test_people_email_discovery_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_PEOPLE_EMAIL_DISCOVERY_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_people_email_discovery_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_PEOPLE_EMAIL_DISCOVERY_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_people_email_discovery_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_PEOPLE_EMAIL_DISCOVERY_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding, f"Finding missing key: {key}" + + +def test_people_email_discovery_parser_items_list_matches_non_empty_lines(): + """items must contain each non-empty line from the output.""" + result = parse(_PEOPLE_EMAIL_DISCOVERY_OUTPUT_FIXTURE) + expected_lines = [l.strip() for l in _PEOPLE_EMAIL_DISCOVERY_OUTPUT_FIXTURE.splitlines() if l.strip()] + assert result["items"] == expected_lines + + +def test_people_email_discovery_parser_empty_output(): + """Parser must handle empty input without raising and return empty findings.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_people_email_discovery_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw must match the original output line.""" + single_line = "admin@example.com\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw"] == "admin@example.com" diff --git a/testing/backend/test_safe_mode_filesystem_targets.py b/testing/backend/test_safe_mode_filesystem_targets.py index 31da5b15c..1de4bf1e4 100644 --- a/testing/backend/test_safe_mode_filesystem_targets.py +++ b/testing/backend/test_safe_mode_filesystem_targets.py @@ -16,6 +16,7 @@ """ import pytest +from unittest.mock import AsyncMock, patch ENDPOINT = "/api/v1/task/start" @@ -57,6 +58,15 @@ def assert_not_blocked_by_host_validation(r): ) +@pytest.fixture(autouse=True) +def _mock_task_execution(): + with patch( + "backend.secuscan.executor.TaskExecutor._execute_command", + new=AsyncMock(return_value=("mocked output", 0)), + ): + yield + + # --------------------------------------------------------------------------- # 1. code_analyzer (category=code) — filesystem paths bypass host validation # --------------------------------------------------------------------------- diff --git a/testing/backend/test_sandbox_blocking_issues.py b/testing/backend/test_sandbox_blocking_issues.py new file mode 100644 index 000000000..8ba237ff6 --- /dev/null +++ b/testing/backend/test_sandbox_blocking_issues.py @@ -0,0 +1,249 @@ +""" +Integration tests for sandbox resource enforcement. + +Covers: +1. Memory-limit classification reliability +2. Timeout enforcement via sandbox_execute +3. Output-limit handling boundary precision +4. Task cancellation and process cleanup +""" + +import asyncio +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from backend.secuscan.models import SandboxConfig +from backend.secuscan.sandbox_executor import ( + sandbox_execute, + classify_memory_violation, +) + + +# --------------------------------------------------------------------------- +# Memory-limit classification +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_memory_limit_detection_comprehensive(): + """All three memory classification conditions produce correct results.""" + limit = 512 * 1024 * 1024 + + # Condition A: SIGSEGV + assert classify_memory_violation(-11, "", 0, limit) is True + assert classify_memory_violation(139, "", 0, limit) is True + + # Condition B: MemoryError / Cannot allocate memory in stderr + assert classify_memory_violation(1, "MemoryError: out of memory", 0, limit) is True + assert classify_memory_violation(1, "Cannot allocate memory", 0, limit) is True + + # Condition C: RSS >= 95% with failure + assert classify_memory_violation(137, "", int(limit * 0.95), limit) is True + assert classify_memory_violation(137, "", int(limit * 0.94), limit) is False + + # Exit code 0 should never classify + assert classify_memory_violation(0, "", int(limit * 0.99), limit) is False + + +@pytest.mark.asyncio +async def test_memory_classification_sigsegv_exit_codes(): + """SIGSEGV signals must always classify as memory_limit.""" + limit = 512 * 1024 * 1024 + for code in (-11, 139): + assert classify_memory_violation(code, "", 0, limit) is True + + +@pytest.mark.asyncio +async def test_memory_classification_stderr_strings(): + """MemoryError / Cannot allocate memory classify; other errors do not.""" + limit = 512 * 1024 * 1024 + assert classify_memory_violation(1, "MemoryError: out of memory", 0, limit) is True + assert classify_memory_violation(1, "Cannot allocate memory", 0, limit) is True + assert classify_memory_violation(1, "Segmentation fault (core dumped)", 0, limit) is False + + +@pytest.mark.asyncio +async def test_memory_classification_rss_delta_heuristic(): + """RSS at or above 95% with non-zero exit classifies; below does not.""" + limit = 512 * 1024 * 1024 + assert classify_memory_violation(137, "", int(limit * 0.95), limit) is True + assert classify_memory_violation(137, "", int(limit * 0.94), limit) is False + assert classify_memory_violation(0, "", int(limit * 0.99), limit) is False + + +@pytest.mark.asyncio +async def test_memory_classification_exit_137_with_rss(): + """Exit 137 (SIGKILL/OOM) + high RSS classifies as memory.""" + limit = 512 * 1024 * 1024 + assert classify_memory_violation(137, "", int(limit * 0.95), limit) is True + assert classify_memory_violation(137, "", int(limit * 0.80), limit) is False + + +@pytest.mark.asyncio +async def test_memory_classification_called_always(): + """Memory classification checked even for successful exit.""" + cfg = SandboxConfig(timeout_seconds=30) + stdout, stderr, exit_code, violation = await sandbox_execute( + [sys.executable, "-c", "print('success')"], + cfg, + ) + assert exit_code == 0 + assert "success" in stdout + assert violation is None or violation == "memory_limit" + + +# --------------------------------------------------------------------------- +# Timeout enforcement +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_timeout_internal_via_sandbox_execute(): + """sandbox_execute with timeout_seconds applies internal timeout.""" + cfg = SandboxConfig(timeout_seconds=1, max_memory_mb=512) + stdout, stderr, exit_code, violation = await sandbox_execute( + [sys.executable, "-c", "import time; time.sleep(30)"], + cfg, + ) + assert violation == "timeout" + assert exit_code != 0 + assert stdout == "" + + +# --------------------------------------------------------------------------- +# Output-limit boundary precision +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_output_limit_exact_boundary(): + """Output capped exactly at max_output_bytes; no bytes beyond.""" + cfg = SandboxConfig(max_output_bytes=1000, timeout_seconds=30) + stdout, stderr, exit_code, violation = await sandbox_execute( + [sys.executable, "-c", "print('x' * 2000)"], + cfg, + ) + total_bytes = len(stdout.encode('utf-8')) + len(stderr.encode('utf-8')) + assert total_bytes <= 1000 + assert violation == "output_limit" + + +@pytest.mark.asyncio +async def test_output_limit_no_partial_chunks(): + """Chunks truncated exactly at boundary; total never exceeds limit.""" + cfg = SandboxConfig(max_output_bytes=512, timeout_seconds=30) + stdout, stderr, exit_code, violation = await sandbox_execute( + [sys.executable, "-c", "print('A' * 1000000)"], + cfg, + ) + total = len(stdout.encode('utf-8')) + len(stderr.encode('utf-8')) + assert total <= 512 + assert violation == "output_limit" + + +@pytest.mark.asyncio +async def test_output_limit_stops_both_readers(): + """Shared state stops both stdout and stderr readers when limit hit.""" + cfg = SandboxConfig(max_output_bytes=256, timeout_seconds=30) + script = """ +import sys +for i in range(100): + print("stdout" * 10) + sys.stderr.write("stderr" * 10 + "\\n") +""" + stdout, stderr, exit_code, violation = await sandbox_execute( + [sys.executable, "-c", script], + cfg, + ) + total_bytes = len(stdout.encode('utf-8')) + len(stderr.encode('utf-8')) + assert total_bytes <= 256 + assert violation == "output_limit" + + +@pytest.mark.asyncio +async def test_output_limit_early_reader_termination(): + """Readers exit immediately when limit is hit (check at loop start).""" + cfg = SandboxConfig(max_output_bytes=100, timeout_seconds=30) + stdout, stderr, exit_code, violation = await sandbox_execute( + [sys.executable, "-c", "print('x' * 10000)"], + cfg, + ) + total = len(stdout.encode('utf-8')) + len(stderr.encode('utf-8')) + assert total <= 100 + assert violation == "output_limit" + + +@pytest.mark.asyncio +async def test_output_limit_lock_prevents_race(): + """asyncio.Lock prevents concurrent readers from exceeding max_bytes.""" + cfg = SandboxConfig(max_output_bytes=512, timeout_seconds=10) + script = ( + "import sys\n" + "for i in range(500):\n" + " sys.stdout.write('a' * 120)\n" + " sys.stderr.write('b' * 120)\n" + ) + stdout, stderr, exit_code, violation = await sandbox_execute( + [sys.executable, "-c", script], + cfg, + ) + total = len(stdout.encode("utf-8")) + len(stderr.encode("utf-8")) + assert total <= 512 + assert violation == "output_limit" + + +@pytest.mark.asyncio +async def test_output_limit_strict_boundary(): + """Output capped at exactly max_output_bytes for multiple limit values.""" + for limit in (256, 511, 1023): + cfg = SandboxConfig(max_output_bytes=limit, timeout_seconds=10) + stdout, stderr, exit_code, violation = await sandbox_execute( + [sys.executable, "-c", f"print('x' * {limit * 10})"], + cfg, + ) + stdout_bytes = len(stdout.encode("utf-8")) + stderr_bytes = len(stderr.encode("utf-8")) + total = stdout_bytes + stderr_bytes + assert total <= limit, f"Limit {limit}: total {total} bytes exceeds limit" + + +# --------------------------------------------------------------------------- +# Cancellation and process cleanup +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_cancellation_with_process_cleanup(): + """Cancelling sandbox_execute raises CancelledError; no orphan process.""" + cfg = SandboxConfig(timeout_seconds=30) + task = asyncio.create_task( + sandbox_execute( + [sys.executable, "-c", "import time; time.sleep(120)"], + cfg, + ) + ) + await asyncio.sleep(0.1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + await asyncio.sleep(0.5) + + +@pytest.mark.asyncio +async def test_cancellation_raises_cancelled_error(): + """Independent assertion that cancellation raises CancelledError.""" + cfg = SandboxConfig(timeout_seconds=60) + task = asyncio.create_task( + sandbox_execute( + [sys.executable, "-c", "import time; time.sleep(60)"], + cfg, + ) + ) + await asyncio.sleep(0.2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task diff --git a/testing/backend/test_sandbox_executor.py b/testing/backend/test_sandbox_executor.py new file mode 100644 index 000000000..eab5f6510 --- /dev/null +++ b/testing/backend/test_sandbox_executor.py @@ -0,0 +1,235 @@ +import asyncio +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from backend.secuscan.models import SandboxConfig +from backend.secuscan.sandbox_executor import ( + sandbox_execute, + _terminate_process, + _build_preexec_fn, + classify_memory_violation, +) + + +@pytest.mark.asyncio +async def test_signal_escalation(): + """Verify terminate() called first, then kill() after grace, wait() called twice.""" + mock_process = MagicMock() + mock_process.returncode = None + mock_process.terminate = MagicMock() + mock_process.kill = MagicMock() + + wait_count = 0 + + async def wait_side_effect(): + nonlocal wait_count + wait_count += 1 + if wait_count == 1: + await asyncio.sleep(999) + + mock_process.wait = wait_side_effect + + with patch("backend.secuscan.sandbox_executor.SIGTERM_GRACE", 0.05): + await _terminate_process(mock_process) + + mock_process.terminate.assert_called_once() + mock_process.kill.assert_called_once() + assert wait_count == 2 + + +class TestMemoryLimitClassification: + @pytest.mark.parametrize("exit_code", [-11, 139]) + def test_sigsegv(self, exit_code): + assert classify_memory_violation( + exit_code=exit_code, + stderr_text="", + rss_bytes=0, + limit_bytes=512 * 1024 * 1024, + ) is True + + def test_memory_error_string(self): + assert classify_memory_violation( + exit_code=1, + stderr_text="MemoryError: unable to allocate", + rss_bytes=0, + limit_bytes=512 * 1024 * 1024, + ) is True + + def test_cannot_allocate_memory(self): + assert classify_memory_violation( + exit_code=1, + stderr_text="Cannot allocate memory", + rss_bytes=0, + limit_bytes=512 * 1024 * 1024, + ) is True + + def test_rss_heuristic(self): + limit_bytes = 512 * 1024 * 1024 + assert classify_memory_violation( + exit_code=137, + stderr_text="", + rss_bytes=limit_bytes, + limit_bytes=limit_bytes, + ) is True + + def test_rss_below_threshold(self): + limit_bytes = 512 * 1024 * 1024 + assert classify_memory_violation( + exit_code=1, + stderr_text="", + rss_bytes=int(limit_bytes * 0.50), + limit_bytes=limit_bytes, + ) is False + + def test_zero_exit_not_classified(self): + limit_bytes = 512 * 1024 * 1024 + assert classify_memory_violation( + exit_code=0, + stderr_text="", + rss_bytes=int(limit_bytes * 0.99), + limit_bytes=limit_bytes, + ) is False + + +@pytest.mark.asyncio +async def test_proactive_output_truncation(): + """Output-limit: reading must stop at boundary, process terminated, violation returned.""" + cfg = SandboxConfig(max_output_bytes=1024, timeout_seconds=30) + stdout, stderr, exit_code, violation_reason = await sandbox_execute( + [sys.executable, "-c", "print('A' * 10000000)"], + cfg, + ) + assert violation_reason == "output_limit" + assert len(stdout) <= 2048 + assert exit_code != 0 + + +@pytest.mark.asyncio +async def test_task_cancellation_safety(): + """Cancellation: cancelling sandbox_execute raises CancelledError, no orphan.""" + cfg = SandboxConfig(timeout_seconds=30) + task = asyncio.create_task( + sandbox_execute( + [sys.executable, "-c", "import time; time.sleep(60)"], + cfg, + ) + ) + await asyncio.sleep(0.2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_timeout_enforcement(): + """Timeout: sandbox_execute with timeout_seconds applies internal timeout.""" + cfg = SandboxConfig(timeout_seconds=1, max_memory_mb=512) + stdout, stderr, exit_code, violation = await sandbox_execute( + [sys.executable, "-c", "import time; time.sleep(30)"], + cfg, + ) + assert violation == "timeout" + assert exit_code != 0 + + +@pytest.mark.asyncio +async def test_platform_guard_non_linux(): + """Platform guard: preexec_fn callable; output/timeout limits active on all platforms.""" + built = _build_preexec_fn(SandboxConfig(max_memory_mb=128)) + assert callable(built) + + cfg = SandboxConfig(max_output_bytes=100, timeout_seconds=30) + stdout, stderr, exit_code, violation_reason = await sandbox_execute( + [sys.executable, "-c", "print('x' * 5000)"], + cfg, + ) + assert violation_reason == "output_limit" + assert len(stdout) < 500 + + cfg2 = SandboxConfig(timeout_seconds=1) + stdout2, stderr2, exit_code2, vr2 = await sandbox_execute( + [sys.executable, "-c", "import time; time.sleep(30)"], + cfg2, + ) + assert vr2 == "timeout" + + +@pytest.mark.asyncio +async def test_sandbox_execute_normal_completion(): + cfg = SandboxConfig(timeout_seconds=30) + stdout, stderr, exit_code, violation_reason = await sandbox_execute( + [sys.executable, "-c", "print('hello world')"], + cfg, + ) + assert "hello world" in stdout + assert exit_code == 0 + assert violation_reason is None + + +@pytest.mark.asyncio +async def test_stderr_returned_separately(): + """stderr: sandbox_execute returns stderr separately from stdout.""" + cfg = SandboxConfig(timeout_seconds=30) + stdout, stderr, exit_code, violation = await sandbox_execute( + [sys.executable, "-c", "import sys; sys.stderr.write('diagnostic info')"], + cfg, + ) + assert "diagnostic info" in stderr + assert exit_code == 0 + + +@pytest.mark.asyncio +async def test_live_output_via_broadcast_callback(): + """Live output: sandbox_execute calls broadcast_callback with output chunks.""" + chunks = [] + + async def capture(chunk, stream_name): + chunks.append((stream_name, chunk.decode())) + + cfg = SandboxConfig(timeout_seconds=30) + await sandbox_execute( + [sys.executable, "-c", "print('live streaming')"], + cfg, + broadcast_callback=capture, + ) + assert any("live streaming" in text for _, text in chunks) + + +def test_sandbox_violation_exception(): + from backend.secuscan.models import SandboxViolation + exc = SandboxViolation("timeout") + assert exc.reason == "timeout" + assert str(exc) == "timeout" + + +@pytest.mark.asyncio +async def test_resolve_sandbox_config_global_defaults(monkeypatch): + from backend.secuscan.sandbox_executor import resolve_sandbox_config + monkeypatch.setattr( + "backend.secuscan.config.settings.sandbox_timeout", + 42, + ) + monkeypatch.setattr( + "backend.secuscan.config.settings.sandbox_memory_mb", + 256, + ) + resolved = resolve_sandbox_config(None) + assert resolved.timeout_seconds == 42 + assert resolved.max_memory_mb == 256 + assert resolved.max_output_bytes == 5_242_880 + + +@pytest.mark.asyncio +async def test_resolve_sandbox_config_plugin_overrides(): + from backend.secuscan.sandbox_executor import resolve_sandbox_config + resolved = resolve_sandbox_config( + SandboxConfig(timeout_seconds=999, max_memory_mb=2048) + ) + assert resolved.timeout_seconds == 999 + assert resolved.max_memory_mb == 2048 + assert resolved.max_output_bytes == 5_242_880 diff --git a/testing/backend/test_sharepoint_scanner_plugin.py b/testing/backend/test_sharepoint_scanner_plugin.py new file mode 100644 index 000000000..0adc586b6 --- /dev/null +++ b/testing/backend/test_sharepoint_scanner_plugin.py @@ -0,0 +1,224 @@ +""" +Contract and parser tests for the sharepoint_scanner plugin. + +These tests load the real plugins/sharepoint_scanner/metadata.json, validate it through +the project PluginMetadataValidator, render commands through the real +PluginManager, and call the real parser.py parse() function. + +Assertions are tied to the actual plugin contract: if metadata.json, +the command template, or parser.py drift, these tests will fail. + +Related to issue #506: Add parser and contract coverage for plugin `sharepoint_scanner` +""" + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.sharepoint_scanner.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "sharepoint_scanner" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + + +def test_sharepoint_scanner_metadata_file_exists(): + """metadata.json must exist at the expected plugin path.""" + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_sharepoint_scanner_metadata_is_valid_json(): + """metadata.json must be valid, parseable JSON.""" + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + + +def test_sharepoint_scanner_passes_validator(): + """ + The full PluginMetadataValidator must accept the plugin without errors. + + This will fail if any required field is missing, the engine type or safety + level is invalid, the command template references an undeclared field, or + the checksum field is absent or malformed. + """ + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, ( + "Plugin validation errors:\n" + + "\n".join(e.display() for e in result.errors) + ) + + +def test_sharepoint_scanner_metadata_id_matches_directory(): + """Plugin id in metadata.json must match the directory name.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "sharepoint_scanner" + + +def test_sharepoint_scanner_engine_is_nuclei(): + """Engine binary must be 'nuclei' -- update this if the underlying tool changes.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "nuclei" + + +def test_sharepoint_scanner_has_required_target_field(): + """Plugin must declare a required 'target' field for the SharePoint URL.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + fields = {f["id"]: f for f in data["fields"]} + assert "target" in fields, "Missing required field: target" + assert fields["target"]["required"] is True + + +def test_sharepoint_scanner_output_parser_is_custom(): + """Parser type must be 'custom', backed by parser.py.""" + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["output"]["parser"] == "custom" + + +def test_sharepoint_scanner_parser_file_exists(): + """parser.py must exist alongside metadata.json.""" + assert (PLUGIN_DIR / "parser.py").exists() + + +# --------------------------------------------------------------------------- +# Command rendering tests via real PluginManager +# --------------------------------------------------------------------------- + + +def test_sharepoint_scanner_command_renders_with_target(setup_test_environment): + """ + PluginManager must produce the correct nuclei command for a SharePoint scan. + + This test will fail if command_template in metadata.json changes or a + placeholder becomes mismatched. + """ + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("sharepoint_scanner", {"target": "https://sharepoint.example.com"}) + + assert command is not None, "build_command returned None for valid inputs" + assert "nuclei" in command + assert "-u" in command + assert "https://sharepoint.example.com" in command + assert "-silent" in command + + +def test_sharepoint_scanner_command_full_token_sequence(setup_test_environment): + """Full rendered command must exactly match the command_template token sequence.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command("sharepoint_scanner", {"target": "https://secuscan.in"}) + + assert command == ["nuclei", "-u", "https://secuscan.in", "-silent"], ( + f"Command template drift detected. Got: {command}" + ) + + +def test_sharepoint_scanner_loaded_by_plugin_manager(setup_test_environment): + """PluginManager must successfully load sharepoint_scanner from the real plugins directory.""" + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("sharepoint_scanner") + assert plugin is not None + assert plugin.id == "sharepoint_scanner" + assert plugin.name == "Sharepoint Scanner" + + +# --------------------------------------------------------------------------- +# Parser contract tests against the real parser.py +# --------------------------------------------------------------------------- + + +_SHAREPOINT_SCANNER_OUTPUT_FIXTURE = ( + "https://sharepoint.example.com/sites/admin [found]\n" + "https://sharepoint.example.com/sites/documents [critical]\n" + "https://sharepoint.example.com/lists [exposed]\n" + "https://sharepoint.example.com/_vti_bin [injection]\n" +) + + +def test_sharepoint_scanner_parser_returns_required_keys(): + """parse() must return a dict with 'findings', 'count', and 'items' keys.""" + result = parse(_SHAREPOINT_SCANNER_OUTPUT_FIXTURE) + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + + +def test_sharepoint_scanner_parser_count_matches_findings(): + """'count' must equal len(findings).""" + result = parse(_SHAREPOINT_SCANNER_OUTPUT_FIXTURE) + assert result["count"] == len(result["findings"]) + + +def test_sharepoint_scanner_parser_finding_has_required_keys(): + """Each finding must have title, category, severity, description, remediation, metadata.""" + result = parse(_SHAREPOINT_SCANNER_OUTPUT_FIXTURE) + assert result["findings"], "Expected at least one finding" + for finding in result["findings"]: + for key in ("title", "category", "severity", "description", "remediation", "metadata"): + assert key in finding, f"Finding missing key: {key}" + + +def test_sharepoint_scanner_parser_critical_and_injection_raise_to_high(): + """Lines containing 'critical' or 'injection' must be classified as 'high' severity.""" + result = parse(_SHAREPOINT_SCANNER_OUTPUT_FIXTURE) + high_findings = [ + f for f in result["findings"] + if "critical" in f["description"].lower() or "injection" in f["description"].lower() + ] + assert high_findings, "Expected at least one high-severity finding" + for finding in high_findings: + assert finding["severity"] == "high" + + +def test_sharepoint_scanner_parser_exposed_or_found_is_at_least_low(): + """Lines containing 'exposed', 'found', or 'detected' must be at least 'low' severity.""" + result = parse(_SHAREPOINT_SCANNER_OUTPUT_FIXTURE) + flagged = [ + f for f in result["findings"] + if any(kw in f["description"].lower() for kw in ("exposed", "found", "detected")) + ] + assert flagged, "Expected at least one low-severity finding from flagged keywords" + for finding in flagged: + assert finding["severity"] in ("low", "high") + + +def test_sharepoint_scanner_parser_items_list_matches_non_empty_lines(): + """items must contain each non-empty line from the output.""" + result = parse(_SHAREPOINT_SCANNER_OUTPUT_FIXTURE) + expected_lines = [l.strip() for l in _SHAREPOINT_SCANNER_OUTPUT_FIXTURE.splitlines() if l.strip()] + assert result["items"] == expected_lines + + +def test_sharepoint_scanner_parser_empty_output(): + """Parser must handle empty input without raising and return empty findings.""" + result = parse("") + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + + +def test_sharepoint_scanner_parser_preserves_raw_line_in_metadata(): + """Each finding's metadata.raw must match the original output line.""" + single_line = "https://sharepoint.example.com/sites/admin [found]\n" + result = parse(single_line) + assert result["findings"] + assert result["findings"][0]["metadata"]["raw"] == "https://sharepoint.example.com/sites/admin [found]" diff --git a/testing/backend/test_sitemap_gen_plugin.py b/testing/backend/test_sitemap_gen_plugin.py new file mode 100644 index 000000000..7c228ae3d --- /dev/null +++ b/testing/backend/test_sitemap_gen_plugin.py @@ -0,0 +1,160 @@ +"""Contract and parser tests for the sitemap_gen plugin.""" + +import asyncio +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager +from plugins.sitemap_gen.parser import parse + +PLUGIN_DIR = REPO_ROOT / "plugins" / "sitemap_gen" +PLUGINS_DIR = REPO_ROOT / "plugins" + + +def test_sitemap_gen_metadata_file_exists(): + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_sitemap_gen_metadata_is_valid_json(): + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + assert data["id"] == "sitemap_gen" + + +def test_sitemap_gen_passes_validator(): + result = PluginMetadataValidator(PLUGIN_DIR).validate() + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_sitemap_gen_metadata_contract(): + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + + assert data["name"] == "Sitemap Generator" + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "katana" + assert data["output"]["parser"] == "custom" + assert data["safety"]["requires_consent"] is True + + fields = {field["id"]: field for field in data["fields"]} + assert fields["target"]["required"] is True + assert fields["depth"]["default"] == 4 + + +def test_sitemap_gen_parser_file_exists(): + assert (PLUGIN_DIR / "parser.py").exists() + + +def test_sitemap_gen_command_uses_default_depth(setup_test_environment): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + "sitemap_gen", + {"target": "https://secuscan.in"}, + ) + + assert command == [ + "katana", + "-u", + "https://secuscan.in", + "-depth", + "4", + "-silent", + ] + + +def test_sitemap_gen_command_respects_explicit_depth(setup_test_environment): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + "sitemap_gen", + {"target": "https://secuscan.in", "depth": 2}, + ) + + assert command == [ + "katana", + "-u", + "https://secuscan.in", + "-depth", + "2", + "-silent", + ] + + +def test_sitemap_gen_loaded_by_plugin_manager(setup_test_environment): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("sitemap_gen") + + assert plugin is not None + assert plugin.id == "sitemap_gen" + assert plugin.name == "Sitemap Generator" + + +_SITEMAP_OUTPUT_FIXTURE = ( + "https://secuscan.in/\n" + "https://secuscan.in/about\n" + "https://secuscan.in/admin [found]\n" + "https://secuscan.in/api/exposed\n" + "https://secuscan.in/debug [critical]\n" +) + + +def test_sitemap_gen_parser_returns_normalized_results(): + result = parse(_SITEMAP_OUTPUT_FIXTURE) + + assert set(result.keys()) == {"findings", "count", "items"} + assert result["count"] == 5 + assert len(result["findings"]) == 5 + assert result["items"] == [ + "https://secuscan.in/", + "https://secuscan.in/about", + "https://secuscan.in/admin [found]", + "https://secuscan.in/api/exposed", + "https://secuscan.in/debug [critical]", + ] + + +def test_sitemap_gen_parser_normalizes_finding_shape_and_severity(): + result = parse(_SITEMAP_OUTPUT_FIXTURE) + findings = result["findings"] + + for finding in findings: + assert set(finding.keys()) == { + "title", + "category", + "severity", + "description", + "remediation", + "metadata", + } + + assert findings[0]["severity"] == "info" + assert findings[1]["severity"] == "info" + assert findings[2]["severity"] == "low" + assert findings[3]["severity"] == "low" + assert findings[4]["severity"] == "high" + + +def test_sitemap_gen_parser_preserves_raw_line_in_metadata(): + result = parse("https://secuscan.in/admin [found]\n") + + assert result["findings"][0]["metadata"]["raw"] == ( + "https://secuscan.in/admin [found]" + ) + + +def test_sitemap_gen_parser_handles_empty_output(): + result = parse("") + + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] \ No newline at end of file diff --git a/testing/backend/test_subdomain_finder_plugin.py b/testing/backend/test_subdomain_finder_plugin.py new file mode 100644 index 000000000..f550a9279 --- /dev/null +++ b/testing/backend/test_subdomain_finder_plugin.py @@ -0,0 +1,181 @@ +"""Contract and parser tests for the subdomain-finder plugin.""" + +import asyncio +import importlib.util +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "subdomain-finder" +PLUGIN_DIR = REPO_ROOT / "plugins" / PLUGIN_ID +PLUGINS_DIR = REPO_ROOT / "plugins" +PARSER_PATH = PLUGIN_DIR / "parser.py" + + +def _load_parser(): + spec = importlib.util.spec_from_file_location("subdomain_finder_parser", PARSER_PATH) + assert spec is not None + assert spec.loader is not None + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + return module.parse + + +parse = _load_parser() + + +def test_subdomain_finder_metadata_file_exists(): + assert (PLUGIN_DIR / "metadata.json").exists() + + +def test_subdomain_finder_metadata_is_valid_json(): + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + + assert data["id"] == PLUGIN_ID + + +def test_subdomain_finder_passes_validator(): + result = PluginMetadataValidator(PLUGIN_DIR).validate() + + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + + +def test_subdomain_finder_metadata_contract(): + data = json.loads((PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8")) + + assert data["name"] == "Subdomain Finder" + assert data["category"] == "recon" + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "subfinder" + assert data["output"]["format"] == "text" + assert data["output"]["parser"] == "custom" + assert data["safety"]["requires_consent"] is False + + fields = {field["id"]: field for field in data["fields"]} + assert fields["target"]["required"] is True + assert fields["target"]["label"] == "Root Domain" + + +def test_subdomain_finder_parser_file_exists(): + assert PARSER_PATH.exists() + + +def test_subdomain_finder_command_renders_target(setup_test_environment): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + PLUGIN_ID, + {"target": "secuscan.in"}, + ) + + assert command == [ + "subfinder", + "-d", + "secuscan.in", + "-silent", + ] + + +def test_subdomain_finder_loaded_by_plugin_manager(setup_test_environment): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin(PLUGIN_ID) + + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "Subdomain Finder" + + +_SUBDOMAIN_OUTPUT_FIXTURE = ( + "api.secuscan.in\n" + "admin.secuscan.in 52.0.200.63\n" + "dev.secuscan.in\n" +) + + +def test_subdomain_finder_parser_returns_normalized_results(): + result = parse(_SUBDOMAIN_OUTPUT_FIXTURE) + + assert set(result.keys()) == {"findings", "count", "structured"} + assert result["count"] == 1 + assert len(result["findings"]) == 1 + assert result["structured"]["type"] == "subdomains" + assert result["structured"]["total_count"] == 3 + + +def test_subdomain_finder_parser_returns_structured_rows(): + result = parse(_SUBDOMAIN_OUTPUT_FIXTURE) + + assert result["structured"]["rows"] == [ + { + "subdomain": "api.secuscan.in", + "ip": "-", + "service": "Found via Recon", + "state": "Live", + }, + { + "subdomain": "admin.secuscan.in", + "ip": "52.0.200.63", + "service": "Found via Recon", + "state": "Live", + }, + { + "subdomain": "dev.secuscan.in", + "ip": "-", + "service": "Found via Recon", + "state": "Live", + }, + ] + + +def test_subdomain_finder_parser_normalizes_finding_shape(): + result = parse(_SUBDOMAIN_OUTPUT_FIXTURE) + finding = result["findings"][0] + + assert set(finding.keys()) == { + "title", + "category", + "severity", + "description", + "remediation", + "metadata", + } + assert finding["title"] == "Discovery: 3 Subdomains Identified" + assert finding["category"] == "Recon" + assert finding["severity"] == "info" + assert finding["metadata"] == {"discovered_count": 3} + + +def test_subdomain_finder_parser_ignores_non_subdomain_lines(): + result = parse( + "not a domain\n" + "random text\n" + "api.secuscan.in\n" + ) + + assert result["structured"]["total_count"] == 1 + assert result["structured"]["rows"][0]["subdomain"] == "api.secuscan.in" + + +def test_subdomain_finder_parser_handles_empty_output(): + result = parse("") + + assert result["findings"] == [] + assert result["count"] == 0 + assert result["structured"] == { + "rows": [], + "type": "subdomains", + "total_count": 0, + } \ No newline at end of file diff --git a/testing/backend/test_task_pagination.py b/testing/backend/test_task_pagination.py index 000de760e..84c8797f2 100644 --- a/testing/backend/test_task_pagination.py +++ b/testing/backend/test_task_pagination.py @@ -2,7 +2,31 @@ Tests for pagination metadata in tasks list endpoint. """ +import asyncio + import pytest +from backend.secuscan.database import get_db + + +async def _insert_task(task_id, plugin_id, status, created_at): + db = await get_db() + await db.execute( + """ + INSERT INTO tasks ( + id, plugin_id, tool_name, target, status, created_at, inputs_json + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + task_id, + plugin_id, + "Test Scanner", + "example.com", + status, + created_at, + "{}", + ), + ) class TestTasksPagination: @@ -57,6 +81,23 @@ def test_invalid_pagination_is_rejected(self, test_client, qs): response = test_client.get(f"/api/v1/tasks?{qs}") assert response.status_code == 422 + def test_status_filter_valid(self, test_client): + response = test_client.get("/api/v1/tasks?status=completed") + assert response.status_code == 200 + + data = response.json() + assert "tasks" in data + assert all(task["status"] == "completed" for task in data["tasks"]) + + def test_status_filter_invalid(self, test_client): + response = test_client.get("/api/v1/tasks?status=invalid-status") + assert response.status_code == 400 + + data = response.json() + assert data["detail"] == ( + "Invalid task status 'invalid-status'. Allowed values: queued, running, completed, failed, cancelled" + ) + def test_first_page_previous_is_null(self, test_client): """Test that previous is None on first page""" response = test_client.get("/api/v1/tasks?page=1&per_page=10") @@ -79,3 +120,31 @@ def test_next_url_preserves_filters(self, test_client): assert "per_page=5" in next_url assert "status=completed" in next_url assert "plugin_id=nmap" in next_url + + def test_next_url_encodes_filtered_pagination_params(self, test_client): + """Test that filtered pagination links URL-encode query values.""" + plugin_id = "web scanner/alpha" + status = "queued" + asyncio.run( + _insert_task("encoded-filter-1", plugin_id, status, "2026-06-02T10:00:00") + ) + asyncio.run( + _insert_task("encoded-filter-2", plugin_id, status, "2026-06-02T09:00:00") + ) + + response = test_client.get( + "/api/v1/tasks", + params={ + "page": 1, + "per_page": 1, + "plugin_id": plugin_id, + "status": status, + }, + ) + assert response.status_code == 200 + + next_url = response.json()["pagination"]["next"] + assert next_url == ( + "/api/v1/tasks?page=2&per_page=1&" + "plugin_id=web+scanner%2Falpha&status=queued" + ) diff --git a/testing/backend/test_website_recon_2_plugin.py b/testing/backend/test_website_recon_2_plugin.py new file mode 100644 index 000000000..4a2b3f60a --- /dev/null +++ b/testing/backend/test_website_recon_2_plugin.py @@ -0,0 +1,243 @@ +""" +Contract and parser tests for the website-recon-2 plugin. + +These tests load the real plugins/website-recon-2/metadata.json, +validate it through the project PluginMetadataValidator, render +commands through the real PluginManager, and call the real parser.py +parse() function. + +Related to issue #519: Add parser and contract coverage for plugin +`website-recon-2` +""" + +import asyncio +import importlib.util +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from backend.secuscan.plugin_validator import PluginMetadataValidator +from backend.secuscan.plugins import PluginManager + +PLUGIN_DIR = REPO_ROOT / "plugins" / "website-recon-2" +PLUGINS_DIR = REPO_ROOT / "plugins" + +# --------------------------------------------------------------------------- +# Load parser dynamically (directory contains '-') +# --------------------------------------------------------------------------- + +_parser_path = PLUGIN_DIR / "parser.py" + +spec = importlib.util.spec_from_file_location( + "website_recon_2_parser", + _parser_path, +) + +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +parse = module.parse + +# --------------------------------------------------------------------------- +# Metadata contract tests +# --------------------------------------------------------------------------- + +def test_website_recon_2_metadata_file_exists(): + assert (PLUGIN_DIR / "metadata.json").exists() + +def test_website_recon_2_metadata_is_valid_json(): + raw = (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + data = json.loads(raw) + assert isinstance(data, dict) + +def test_website_recon_2_passes_validator(): + result = PluginMetadataValidator(PLUGIN_DIR).validate() + + assert result.valid, "Plugin validation errors:\n" + "\n".join( + e.display() for e in result.errors + ) + +def test_website_recon_2_metadata_id_matches_directory(): + data = json.loads( + (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + ) + + assert data["id"] == "website-recon-2" + +def test_website_recon_2_engine_is_httpx(): + data = json.loads( + (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + ) + + assert data["engine"]["type"] == "cli" + assert data["engine"]["binary"] == "httpx" + +def test_website_recon_2_has_required_target_field(): + data = json.loads( + (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + ) + + fields = {f["id"]: f for f in data["fields"]} + + assert "target" in fields + assert fields["target"]["required"] is True + +def test_website_recon_2_output_parser_is_custom(): + data = json.loads( + (PLUGIN_DIR / "metadata.json").read_text(encoding="utf-8") + ) + + assert data["output"]["parser"] == "custom" + +def test_website_recon_2_parser_file_exists(): + assert (PLUGIN_DIR / "parser.py").exists() + +# --------------------------------------------------------------------------- +# Command rendering tests +# --------------------------------------------------------------------------- + +def test_website_recon_2_command_renders_with_target( + setup_test_environment, +): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + "website-recon-2", + {"target": "https://secuscan.in"}, + ) + + assert command is not None + + assert command[0] == "httpx" + assert "-u" in command + assert "https://secuscan.in" in command + assert "-title" in command + assert "-status-code" in command + assert "-tech-detect" in command + assert "-silent" in command + +def test_website_recon_2_command_full_token_sequence( + setup_test_environment, +): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + "website-recon-2", + {"target": "https://secuscan.in"}, + ) + + assert command == [ + "httpx", + "-u", + "https://secuscan.in", + "-title", + "-status-code", + "-tech-detect", + "-silent", + ] + +def test_website_recon_2_drops_target_token_when_absent( + setup_test_environment, +): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + rendered = manager.build_command( + "website-recon-2", + {}, + ) + + assert rendered is not None + assert not any("{" in token for token in rendered) + + populated = manager.build_command( + "website-recon-2", + {"target": "https://secuscan.in"}, + ) + + assert "https://secuscan.in" in populated + +def test_website_recon_2_loaded_by_plugin_manager( + setup_test_environment, +): + manager = PluginManager(str(PLUGINS_DIR)) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("website-recon-2") + + assert plugin is not None + assert plugin.id == "website-recon-2" + +# --------------------------------------------------------------------------- +# Parser contract tests +# --------------------------------------------------------------------------- + +_HTTPX_OUTPUT_FIXTURE = ( + "https://secuscan.in [200] [SecuScan] [Cloudflare]\n" + "https://api.secuscan.in alive\n" + "https://admin.secuscan.in exposed\n" + "https://staging.secuscan.in found\n" + "https://dev.secuscan.in\n" +) + +def test_website_recon_2_parser_returns_required_keys(): + result = parse(_HTTPX_OUTPUT_FIXTURE) + + assert isinstance(result, dict) + assert "findings" in result + assert "count" in result + assert "items" in result + +def test_website_recon_2_parser_count_matches_findings(): + result = parse(_HTTPX_OUTPUT_FIXTURE) + + assert result["count"] == len(result["findings"]) + +def test_website_recon_2_parser_finding_has_required_keys(): + result = parse(_HTTPX_OUTPUT_FIXTURE) + + assert result["findings"] + + for finding in result["findings"]: + for key in ( + "title", + "category", + "severity", + "description", + "remediation", + "metadata", + ): + assert key in finding + +def test_website_recon_2_parser_severity_classification(): + result = parse(_HTTPX_OUTPUT_FIXTURE) + + findings = result["findings"] + + assert findings[0]["severity"] == "info" + assert findings[1]["severity"] == "low" + assert findings[2]["severity"] == "low" + assert findings[3]["severity"] == "low" + assert findings[4]["severity"] == "info" + +def test_website_recon_2_parser_empty_output(): + result = parse("") + + assert result["findings"] == [] + assert result["count"] == 0 + assert result["items"] == [] + +def test_website_recon_2_parser_preserves_raw_line_in_metadata(): + line = "https://admin.secuscan.in exposed\n" + + result = parse(line) + + assert result["findings"] + assert ( + result["findings"][0]["metadata"]["raw_line"] + == "https://admin.secuscan.in exposed" + ) \ No newline at end of file diff --git a/testing/backend/test_workflow_api_edge_cases.py b/testing/backend/test_workflow_api_edge_cases.py new file mode 100644 index 000000000..8a7c6f250 --- /dev/null +++ b/testing/backend/test_workflow_api_edge_cases.py @@ -0,0 +1,291 @@ +import json +import pytest +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi.testclient import TestClient + +from backend.secuscan.main import app + + +# --------------------------------------------------------------------------- +# Shared test client +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +# --------------------------------------------------------------------------- +# Helpers — payload / step builders +# --------------------------------------------------------------------------- + +def _step(plugin_id="port_scan", **extra): + """Return a minimal valid step dict, with optional field overrides.""" + s = {"plugin_id": plugin_id, "params": {}} + s.update(extra) + return s + + +def _payload(steps=None, **extra): + """Return a minimal valid workflow payload.""" + base = { + "name": "edge-case-workflow", + "steps": [_step()] if steps is None else steps, + } + base.update(extra) + return base + + +# --------------------------------------------------------------------------- +# Helper — assert the response is a well-formed JSON error +# --------------------------------------------------------------------------- + +def _assert_error_response(response, *, expected_statuses=(400, 422)): + """Assert status is one of expected_statuses and body is a JSON object.""" + assert response.status_code in expected_statuses, ( + f"Expected one of {expected_statuses}, got {response.status_code}. " + f"Body: {response.text}" + ) + assert isinstance(response.json(), dict), ( + f"Expected JSON object body, got: {response.text}" + ) + + +# --------------------------------------------------------------------------- +# Mock-DB factory for valid-creation tests +# --------------------------------------------------------------------------- + +def _make_fake_row(name="edge-case-workflow", schedule_seconds=None): + """Return a dict that mimics the DB row the route would insert/fetch.""" + return { + "id": "test-wf-id-001", + "name": name, + "enabled": 1, + "schedule_seconds": schedule_seconds, + "steps_json": json.dumps([_step()]), + "last_run_at": None, + "created_at": "2026-01-01T00:00:00", + } + + +def _make_mock_db(fake_row): + """ + Return a mock that behaves as both an awaitable and an async context + manager, so it works regardless of how get_db is consumed in routes.py. + """ + mock_db = MagicMock() + mock_db.execute = AsyncMock(return_value=None) + mock_db.fetchone = AsyncMock(return_value=fake_row) + mock_db.commit = AsyncMock(return_value=None) + mock_db.close = AsyncMock(return_value=None) + + # Support ``async with get_db() as db`` + mock_db.__aenter__ = AsyncMock(return_value=mock_db) + mock_db.__aexit__ = AsyncMock(return_value=False) + + return mock_db + + +def _patch_get_db(fake_row): + """ + Return a patch context manager that makes get_db yield/return mock_db. + Works for both ``Depends(get_db)`` with an async-generator and a plain + async function. + """ + mock_db = _make_mock_db(fake_row) + + @asynccontextmanager + async def _fake_get_db(): + yield mock_db + + # FastAPI resolves Depends by calling the function. We replace get_db + # with an async generator factory so both generator and non-generator + # call-sites receive the same mock_db object. + async def _get_db_override(): + yield mock_db + + return patch( + "backend.secuscan.routes.get_db", + new=_get_db_override, + ) + + +# --------------------------------------------------------------------------- +# Empty steps — rejected before DB is touched +# --------------------------------------------------------------------------- + +class TestEmptySteps: + """POST /api/v1/workflows with an empty or missing steps field is rejected.""" + + def test_empty_steps_list_is_rejected(self, client): + response = client.post("/api/v1/workflows", json=_payload(steps=[])) + _assert_error_response(response) + + def test_empty_steps_error_body_is_json_object(self, client): + response = client.post("/api/v1/workflows", json=_payload(steps=[])) + assert response.status_code in (400, 422) + assert isinstance(response.json(), dict) + + def test_missing_steps_field_is_rejected(self, client): + """Omitting steps entirely is equivalent to an empty list.""" + response = client.post( + "/api/v1/workflows", json={"name": "no-steps-workflow"} + ) + _assert_error_response(response) + + +# --------------------------------------------------------------------------- +# Malformed step payloads — rejected before DB is touched +# --------------------------------------------------------------------------- + +class TestMalformedSteps: + """Steps that are structurally invalid must cause the request to fail.""" + + def test_step_missing_plugin_id_is_rejected(self, client): + bad_step = {"params": {"target": "127.0.0.1"}} + response = client.post( + "/api/v1/workflows", json=_payload(steps=[bad_step]) + ) + _assert_error_response(response) + + def test_step_plugin_id_wrong_type_integer_is_rejected(self, client): + bad_step = {"plugin_id": 42, "params": {}} + response = client.post( + "/api/v1/workflows", json=_payload(steps=[bad_step]) + ) + _assert_error_response(response) + + def test_step_is_null_is_rejected(self, client): + response = client.post( + "/api/v1/workflows", json=_payload(steps=[None]) + ) + _assert_error_response(response) + + def test_step_is_bare_string_is_rejected(self, client): + response = client.post( + "/api/v1/workflows", json=_payload(steps=["run_port_scan"]) + ) + _assert_error_response(response) + + def test_steps_value_is_dict_not_list_is_rejected(self, client): + """The steps field must be a list, not a dict.""" + response = client.post( + "/api/v1/workflows", + json=_payload(steps={"plugin_id": "port_scan"}), + ) + _assert_error_response(response) + + def test_one_malformed_step_among_valid_steps_is_rejected(self, client): + """A single bad step in an otherwise valid list must still fail.""" + mixed = [ + _step("port_scan"), # valid + {"params": {"target": "x"}}, # missing plugin_id + ] + response = client.post( + "/api/v1/workflows", json=_payload(steps=mixed) + ) + _assert_error_response(response) + + +# --------------------------------------------------------------------------- +# Invalid schedule_seconds — rejected before DB is touched +# --------------------------------------------------------------------------- + +class TestInvalidScheduleSeconds: + """schedule_seconds must be a positive integer ≥ 1 when supplied.""" + + def test_schedule_seconds_zero_is_rejected(self, client): + response = client.post( + "/api/v1/workflows", json=_payload(schedule_seconds=0) + ) + _assert_error_response(response) + + def test_schedule_seconds_negative_is_rejected(self, client): + response = client.post( + "/api/v1/workflows", json=_payload(schedule_seconds=-60) + ) + _assert_error_response(response) + + def test_schedule_seconds_string_is_rejected(self, client): + response = client.post( + "/api/v1/workflows", json=_payload(schedule_seconds="daily") + ) + _assert_error_response(response) + + def test_schedule_seconds_float_is_rejected(self, client): + response = client.post( + "/api/v1/workflows", json=_payload(schedule_seconds=3.14) + ) + _assert_error_response(response) + + def test_schedule_seconds_list_is_rejected(self, client): + response = client.post( + "/api/v1/workflows", json=_payload(schedule_seconds=[60, 120]) + ) + _assert_error_response(response) + + +# --------------------------------------------------------------------------- +# Valid / boundary schedule_seconds — patch get_db for determinism +# --------------------------------------------------------------------------- + +class TestValidScheduleSeconds: + """Boundary-valid schedule_seconds values (and absence thereof) are accepted.""" + + def test_schedule_seconds_omitted_is_accepted(self, client): + fake_row = _make_fake_row() + with _patch_get_db(fake_row): + response = client.post("/api/v1/workflows", json=_payload()) + assert response.status_code in (200, 201), ( + f"Workflow without schedule should be accepted, " + f"got {response.status_code}. Body: {response.text}" + ) + + def test_schedule_seconds_null_is_accepted(self, client): + """Explicitly passing null must be treated the same as omitting it.""" + fake_row = _make_fake_row() + with _patch_get_db(fake_row): + response = client.post( + "/api/v1/workflows", json=_payload(schedule_seconds=None) + ) + assert response.status_code in (200, 201), ( + f"schedule_seconds=null should be accepted, " + f"got {response.status_code}. Body: {response.text}" + ) + + def test_schedule_seconds_minimum_boundary_60_is_accepted(self, client): + """60 seconds is the minimum meaningful schedule interval.""" + fake_row = _make_fake_row(schedule_seconds=60) + with _patch_get_db(fake_row): + response = client.post( + "/api/v1/workflows", json=_payload(schedule_seconds=60) + ) + assert response.status_code in (200, 201), ( + f"schedule_seconds=60 should be accepted, " + f"got {response.status_code}. Body: {response.text}" + ) + + def test_schedule_seconds_maximum_boundary_86400_is_accepted(self, client): + """86 400 seconds (24 h) is the maximum allowed schedule interval.""" + fake_row = _make_fake_row(schedule_seconds=86400) + with _patch_get_db(fake_row): + response = client.post( + "/api/v1/workflows", json=_payload(schedule_seconds=86400) + ) + assert response.status_code in (200, 201), ( + f"schedule_seconds=86400 should be accepted, " + f"got {response.status_code}. Body: {response.text}" + ) + + def test_valid_creation_response_body_is_json_object(self, client): + """A successful creation response body must be a JSON object.""" + fake_row = _make_fake_row() + with _patch_get_db(fake_row): + response = client.post("/api/v1/workflows", json=_payload()) + assert response.status_code in (200, 201) + assert isinstance(response.json(), dict), ( + f"Expected JSON object, got: {response.text}" + ) diff --git a/testing/backend/unit/fixtures/audit/npm_audit_real_shape.json b/testing/backend/unit/fixtures/audit/npm_audit_real_shape.json new file mode 100644 index 000000000..98ed52bd6 --- /dev/null +++ b/testing/backend/unit/fixtures/audit/npm_audit_real_shape.json @@ -0,0 +1,43 @@ +{ + "vulnerabilities": { + "framer-motion": { + "name": "framer-motion", + "severity": "high", + "via": [ + { + "source": 1085097, + "name": "framer-motion", + "dependency": "framer-motion", + "title": "framer-motion vulnerable to RCE", + "url": "https://github.com/advisories/GHSA-jqrj-82ww", + "severity": "high", + "cvss": { + "score": 9.8, + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" + }, + "cwe": [ + "CWE-94" + ] + } + ] + } + }, + "metadata": { + "vulnerabilities": { + "info": 0, + "low": 0, + "moderate": 0, + "high": 1, + "critical": 0, + "total": 1 + }, + "dependencies": { + "prod": 100, + "dev": 20, + "optional": 0, + "peer": 0, + "peerOptional": 0, + "total": 120 + } + } +} diff --git a/testing/backend/unit/fixtures/audit/pip_audit_real_shape.json b/testing/backend/unit/fixtures/audit/pip_audit_real_shape.json new file mode 100644 index 000000000..92ed9e97b --- /dev/null +++ b/testing/backend/unit/fixtures/audit/pip_audit_real_shape.json @@ -0,0 +1,19 @@ +[ + { + "name": "requests", + "version": "2.25.0", + "vulns": [ + { + "id": "GHSA-c5u2-73g7-4w73", + "aliases": [ + "CVE-2021-28216" + ], + "description": "High severity vulnerability in requests package.", + "fix_versions": [ + "2.28.0" + ], + "severity": "high" + } + ] + } +] diff --git a/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json b/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json index be121e37f..5264d21a9 100644 --- a/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json +++ b/testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json @@ -21,14 +21,16 @@ "label": "Target Host", "type": "text", "required": true, - "placeholder": "192.168.1.1" + "placeholder": "192.168.1.1", + "help": "IP address or hostname to ping" }, { "id": "count", "label": "Packet Count", "type": "number", "required": false, - "default": 4 + "default": 4, + "help": "Number of ICMP packets to send" } ], "output": { diff --git a/testing/backend/unit/fixtures/sniper/sample_output.txt b/testing/backend/unit/fixtures/sniper/sample_output.txt new file mode 100644 index 000000000..0e199e5ae --- /dev/null +++ b/testing/backend/unit/fixtures/sniper/sample_output.txt @@ -0,0 +1,3 @@ +Sniper simulation started +target=secuscan.in +status=planned_exploit_path diff --git a/testing/backend/unit/fixtures/spider/sample_output.txt b/testing/backend/unit/fixtures/spider/sample_output.txt new file mode 100644 index 000000000..165d6c147 --- /dev/null +++ b/testing/backend/unit/fixtures/spider/sample_output.txt @@ -0,0 +1,3 @@ +https://secuscan.in/ +https://secuscan.in/about +found 2 endpoints during crawl diff --git a/testing/backend/unit/fixtures/sqli_exploiter/sample_output.txt b/testing/backend/unit/fixtures/sqli_exploiter/sample_output.txt new file mode 100644 index 000000000..f55fd94a1 --- /dev/null +++ b/testing/backend/unit/fixtures/sqli_exploiter/sample_output.txt @@ -0,0 +1,4 @@ +sqlmap scan started +target=https://vuln.local/item?id=1 +parameter 'id' is vulnerable to SQL injection +exploit confirmed: database enumeration complete diff --git a/testing/backend/unit/fixtures/subdomain_discovery/sample_output.txt b/testing/backend/unit/fixtures/subdomain_discovery/sample_output.txt new file mode 100644 index 000000000..699ff2012 --- /dev/null +++ b/testing/backend/unit/fixtures/subdomain_discovery/sample_output.txt @@ -0,0 +1,3 @@ +api.secuscan.in +staging.secuscan.in +dev.secuscan.in diff --git a/testing/backend/unit/fixtures/subdomain_takeover/sample_output.txt b/testing/backend/unit/fixtures/subdomain_takeover/sample_output.txt new file mode 100644 index 000000000..cac094b93 --- /dev/null +++ b/testing/backend/unit/fixtures/subdomain_takeover/sample_output.txt @@ -0,0 +1,3 @@ +subdomain takeover scan started +staging.secuscan.in points to unclaimed S3 bucket +dangling CNAME detected for legacy.secuscan.in diff --git a/testing/backend/unit/fixtures/theharvester/sample_output.txt b/testing/backend/unit/fixtures/theharvester/sample_output.txt new file mode 100644 index 000000000..b1519ee0e --- /dev/null +++ b/testing/backend/unit/fixtures/theharvester/sample_output.txt @@ -0,0 +1,3 @@ +[*] Target: secuscan.in +contact@secuscan.in +api.secuscan.in found alive diff --git a/testing/backend/unit/fixtures/uncover/sample_output.txt b/testing/backend/unit/fixtures/uncover/sample_output.txt new file mode 100644 index 000000000..40fc685da --- /dev/null +++ b/testing/backend/unit/fixtures/uncover/sample_output.txt @@ -0,0 +1,3 @@ +https://api.secuscan.in:443 +https://staging.secuscan.in:443 +host 203.0.113.10 found via search diff --git a/testing/backend/unit/fixtures/waf_detector/sample_output.txt b/testing/backend/unit/fixtures/waf_detector/sample_output.txt new file mode 100644 index 000000000..49e591daf --- /dev/null +++ b/testing/backend/unit/fixtures/waf_detector/sample_output.txt @@ -0,0 +1,3 @@ +Checking https://secuscan.in +Detected Cloudflare WAF +Scan completed \ No newline at end of file diff --git a/testing/backend/unit/fixtures/xss_exploiter/sample_output.txt b/testing/backend/unit/fixtures/xss_exploiter/sample_output.txt new file mode 100644 index 000000000..d86707547 --- /dev/null +++ b/testing/backend/unit/fixtures/xss_exploiter/sample_output.txt @@ -0,0 +1,4 @@ +XSS probe simulation +target=https://vuln.local/search?q=test +payload=<script>alert(1)</script> +XSS exploit confirmed on parameter q diff --git a/testing/backend/unit/fixtures/zap_scanner/sample_output.txt b/testing/backend/unit/fixtures/zap_scanner/sample_output.txt new file mode 100644 index 000000000..8a779ab42 --- /dev/null +++ b/testing/backend/unit/fixtures/zap_scanner/sample_output.txt @@ -0,0 +1,3 @@ +ZAP connector placeholder scan +target=https://secuscan.in +mode=dast \ No newline at end of file diff --git a/testing/backend/unit/test_admin_network_policy.py b/testing/backend/unit/test_admin_network_policy.py new file mode 100644 index 000000000..d53dbfb6e --- /dev/null +++ b/testing/backend/unit/test_admin_network_policy.py @@ -0,0 +1,197 @@ +import pytest +from unittest.mock import patch, MagicMock +from backend.secuscan.config import settings +from backend.secuscan.network_policy import get_policy_engine, PolicyAction + +class TestAdminNetworkPolicySecurity: + """Verify security of the `/admin` surface.""" + + def test_unconfigured_api_key_blocks_with_500(self, test_client, monkeypatch): + """When admin_api_key is unconfigured, endpoints should return HTTP 500.""" + monkeypatch.setattr(settings, "admin_api_key", None) + + # GET policy config + res = test_client.get("/api/v1/admin/network-policy") + assert res.status_code == 500 + assert "not configured" in res.json()["detail"].lower() + + # POST allow rule + res = test_client.post("/api/v1/admin/network-policy/allow", json={"cidr": "1.1.1.1/32"}) + assert res.status_code == 500 + + # POST deny rule + res = test_client.post("/api/v1/admin/network-policy/deny", json={"cidr": "2.2.2.2/32"}) + assert res.status_code == 500 + + # GET audit log + res = test_client.get("/api/v1/admin/network-audit-log") + assert res.status_code == 500 + + # GET audit log export + res = test_client.get("/api/v1/admin/network-audit-log/export") + assert res.status_code == 500 + + def test_weak_api_key_blocks_with_500(self, test_client, monkeypatch): + """When admin_api_key is too short/weak (< 16 chars), endpoints should return HTTP 500.""" + monkeypatch.setattr(settings, "admin_api_key", "too-short-key") # 13 chars + + res = test_client.get("/api/v1/admin/network-policy") + assert res.status_code == 500 + assert "too weak" in res.json()["detail"].lower() + + def test_missing_api_key_returns_401(self, test_client, monkeypatch): + """When key is configured but missing in request, return HTTP 401.""" + monkeypatch.setattr(settings, "admin_api_key", "valid-admin-key-long") + + res = test_client.get("/api/v1/admin/network-policy") + assert res.status_code == 401 + assert "missing" in res.json()["detail"].lower() + + def test_invalid_api_key_returns_401(self, test_client, monkeypatch): + """When key is configured but invalid key is sent, return HTTP 401.""" + monkeypatch.setattr(settings, "admin_api_key", "valid-admin-key-long") + + # Invalid key in X-API-Key header + res = test_client.get("/api/v1/admin/network-policy", headers={"X-API-Key": "wrong-key"}) + assert res.status_code == 401 + + # Invalid key in Authorization Bearer header + res = test_client.get("/api/v1/admin/network-policy", headers={"Authorization": "Bearer wrong-key"}) + assert res.status_code == 401 + + # Invalid key in raw Authorization header + res = test_client.get("/api/v1/admin/network-policy", headers={"Authorization": "wrong-key"}) + assert res.status_code == 401 + + def test_valid_api_key_in_header_allows_access(self, test_client, monkeypatch): + """Valid key in X-API-Key header should allow access.""" + monkeypatch.setattr(settings, "admin_api_key", "valid-admin-key-long") + + res = test_client.get("/api/v1/admin/network-policy", headers={"X-API-Key": "valid-admin-key-long"}) + assert res.status_code == 200 + assert "allowlist" in res.json() + + def test_valid_api_key_in_bearer_token_allows_access(self, test_client, monkeypatch): + """Valid key in Authorization Bearer header should allow access.""" + monkeypatch.setattr(settings, "admin_api_key", "valid-admin-key-long") + + res = test_client.get("/api/v1/admin/network-policy", headers={"Authorization": "Bearer valid-admin-key-long"}) + assert res.status_code == 200 + + def test_valid_api_key_in_auth_header_allows_access(self, test_client, monkeypatch): + """Valid key in raw Authorization header should allow access.""" + monkeypatch.setattr(settings, "admin_api_key", "valid-admin-key-long") + + res = test_client.get("/api/v1/admin/network-policy", headers={"Authorization": "valid-admin-key-long"}) + assert res.status_code == 200 + +class TestAdminNetworkPolicyOperations: + """Verify CRUD endpoints for allowlist/denylist rules.""" + + @pytest.fixture(autouse=True) + def configure_auth(self, monkeypatch): + monkeypatch.setattr(settings, "admin_api_key", "secret-test-key-long") + + def test_add_and_retrieve_rules(self, test_client): + """Verify we can add rules and get the current configuration.""" + headers = {"X-API-Key": "secret-test-key-long"} + + # Empty configuration check + engine = get_policy_engine() + engine.allowlist.clear() + engine.denylist.clear() + + # Add allow rule + res = test_client.post( + "/api/v1/admin/network-policy/allow", + json={"cidr": "8.8.8.0/24", "reason": "Google DNS Subnet"}, + headers=headers, + ) + assert res.status_code == 200 + assert res.json()["status"] == "success" + + # Add deny rule + res = test_client.post( + "/api/v1/admin/network-policy/deny", + json={"cidr": "10.0.0.0/8", "reason": "Internal"}, + headers=headers, + ) + assert res.status_code == 200 + assert res.json()["status"] == "success" + + # Get policy config and verify rules + res = test_client.get("/api/v1/admin/network-policy", headers=headers) + assert res.status_code == 200 + data = res.json() + assert len(data["allowlist"]) == 1 + assert data["allowlist"][0]["cidr"] == "8.8.8.0/24" + assert len(data["denylist"]) == 1 + assert data["denylist"][0]["cidr"] == "10.0.0.0/8" + + def test_add_invalid_cidr_returns_400(self, test_client): + """Verify adding an invalid CIDR network returns HTTP 400.""" + headers = {"X-API-Key": "secret-test-key-long"} + + res = test_client.post( + "/api/v1/admin/network-policy/allow", + json={"cidr": "invalid-cidr"}, + headers=headers, + ) + assert res.status_code == 400 + assert "invalid" in res.json()["detail"].lower() + +class TestAdminNetworkAuditLog: + """Verify auditing and log querying/exporting.""" + + @pytest.fixture(autouse=True) + def configure_auth(self, monkeypatch): + monkeypatch.setattr(settings, "admin_api_key", "secret-test-key-long") + + def test_query_and_export_audit_logs(self, test_client): + """Verify querying audit log entries and exporting them.""" + headers = {"X-API-Key": "secret-test-key-long"} + + engine = get_policy_engine() + engine.allowlist.clear() + engine.denylist.clear() + engine.audit_entries.clear() + + # Generate some audit log entries + engine.check_access("1.1.1.1", plugin_id="scanner_a", task_id="task_123") + engine.check_access("8.8.8.8", plugin_id="scanner_b", task_id="task_456") + + # Query audit logs + res = test_client.get("/api/v1/admin/network-audit-log", headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["total"] == 2 + assert len(data["entries"]) == 2 + + # Query with plugin filtering + res = test_client.get("/api/v1/admin/network-audit-log?plugin_id=scanner_a", headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["total"] == 1 + assert data["entries"][0]["plugin_id"] == "scanner_a" + + # Query with action filtering + res = test_client.get("/api/v1/admin/network-audit-log?action=deny", headers=headers) + assert res.status_code == 200 + data = res.json() + assert len(data["entries"]) == 2 # Denied by default because allowlist was empty + + # Export audit log as JSON + res = test_client.get("/api/v1/admin/network-audit-log/export?format=json", headers=headers) + assert res.status_code == 200 + assert res.headers["content-type"] == "application/json" + exported_data = res.json() + assert len(exported_data) == 2 + + # Export audit log as CSV + res = test_client.get("/api/v1/admin/network-audit-log/export?format=csv", headers=headers) + assert res.status_code == 200 + assert "text/csv" in res.headers["content-type"] + csv_text = res.text + assert "timestamp,plugin_id,task_id" in csv_text + assert "scanner_a" in csv_text + assert "scanner_b" in csv_text diff --git a/testing/backend/unit/test_ai_summary.py b/testing/backend/unit/test_ai_summary.py new file mode 100644 index 000000000..048b92325 --- /dev/null +++ b/testing/backend/unit/test_ai_summary.py @@ -0,0 +1,190 @@ +""" +Unit tests for backend/secuscan/ai_summary.py + +Run with: + ./testing/test_python.sh +or directly: + python -m pytest testing/backend/unit/test_ai_summary.py -v +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from backend.secuscan.ai_summary import _build_prompt, generate_summary + + +SAMPLE_FINDINGS = [ + {"title": "SQL Injection in login form", "severity": "critical", "category": "injection"}, + {"title": "Reflected XSS in search parameter", "severity": "high", "category": "xss"}, + {"title": "Missing X-Frame-Options header", "severity": "medium", "category": "headers"}, + {"title": "Outdated jQuery version", "severity": "low", "category": "components"}, + {"title": "Server version disclosed", "severity": "info", "category": "information-disclosure"}, +] + + +def _mock_response(text: str) -> MagicMock: + choice = MagicMock() + choice.message.content = text + resp = MagicMock() + resp.choices = [choice] + return resp + + +class TestBuildPrompt: + def test_includes_total_count(self): + assert "Total findings: 5" in _build_prompt(SAMPLE_FINDINGS) + + def test_includes_severity_counts(self): + prompt = _build_prompt(SAMPLE_FINDINGS) + assert "1 critical" in prompt + assert "1 high" in prompt + assert "1 medium" in prompt + + def test_top_findings_contains_critical_and_high(self): + prompt = _build_prompt(SAMPLE_FINDINGS) + assert "SQL Injection" in prompt + assert "Reflected XSS" in prompt + + def test_low_and_info_not_in_top_findings_section(self): + top_section = _build_prompt(SAMPLE_FINDINGS).split("Most critical findings:")[1] + assert "Outdated jQuery" not in top_section + assert "Server version" not in top_section + + def test_empty_findings_produces_valid_prompt(self): + prompt = _build_prompt([]) + assert "Total findings: 0" in prompt + assert "none in critical/high range" in prompt + + def test_no_hostnames_or_ips_in_prompt(self): + prompt = _build_prompt([{ + "title": "Open redirect", "severity": "high", "category": "redirect", + "target": "http://internal-db.corp:5432", "host": "10.0.0.1", + }]) + assert "10.0.0.1" not in prompt + assert "internal-db.corp" not in prompt + + def test_category_distribution_present(self): + prompt = _build_prompt(SAMPLE_FINDINGS) + assert any(cat in prompt for cat in ("injection", "xss", "headers")) + + +class TestGenerateSummary: + def test_returns_summary_string(self): + expected = "The scan found 5 findings including a critical SQL injection." + with patch("backend.secuscan.ai_summary.OpenAI") as mock_cls: + mock_cls.return_value.chat.completions.create.return_value = _mock_response(expected) + result = generate_summary(SAMPLE_FINDINGS, "gpt-4o-mini", "test-key") + assert result == expected + + def test_strips_whitespace(self): + with patch("backend.secuscan.ai_summary.OpenAI") as mock_cls: + mock_cls.return_value.chat.completions.create.return_value = _mock_response(" Summary. ") + result = generate_summary(SAMPLE_FINDINGS, "gpt-4o-mini", "key") + assert result == "Summary." + + def test_passes_base_url_to_client(self): + with patch("backend.secuscan.ai_summary.OpenAI") as mock_cls: + mock_cls.return_value.chat.completions.create.return_value = _mock_response("ok") + generate_summary( + SAMPLE_FINDINGS, model="llama3", api_key="ollama", + base_url="http://localhost:11434/v1", + ) + mock_cls.assert_called_once_with( + api_key="ollama", base_url="http://localhost:11434/v1", timeout=15.0 + ) + + def test_returns_empty_string_on_llm_exception(self): + with patch("backend.secuscan.ai_summary.OpenAI") as mock_cls: + mock_cls.return_value.chat.completions.create.side_effect = RuntimeError("conn refused") + assert generate_summary(SAMPLE_FINDINGS, "gpt-4o-mini", "key") == "" + + def test_returns_empty_string_for_empty_findings(self): + with patch("backend.secuscan.ai_summary.OpenAI") as mock_cls: + result = generate_summary([], "gpt-4o-mini", "key") + assert result == "" + mock_cls.assert_not_called() + + def test_returns_empty_string_when_openai_none(self): + with patch("backend.secuscan.ai_summary.OpenAI", None): + result = generate_summary(SAMPLE_FINDINGS, "gpt-4o-mini", "key") + assert result == "" + + +class TestReportGeneratorAiSummary: + def _task(self): + return { + "id": "task-ai-test", + "tool_name": "http_inspector", + "plugin_id": "http_inspector", + "target": "https://example.com", + "status": "completed", + "created_at": "2026-06-01T10:00:00", + } + + def _result(self): + return { + "structured": { + "findings": [{ + "title": "Exposed admin panel", + "category": "Exposure", + "severity": "high", + "description": "Admin reachable without auth.", + "remediation": "Restrict access.", + "proof": "HTTP 200 /admin", + "cve": "CVE-2026-0001", + }] + } + } + + def test_html_report_generates_without_ai_summary(self): + from backend.secuscan.reporting import ReportGenerator + html = ReportGenerator.generate_html_report(self._task(), self._result()) + assert "Exposed admin panel" in html + assert "Executive Overview" in html + assert "AI Executive Summary" not in html + + def test_pdf_report_generates_without_ai_summary(self): + from backend.secuscan.reporting import ReportGenerator + pdf_bytes = ReportGenerator.generate_pdf_report(self._task(), self._result()) + assert pdf_bytes.startswith(b"%PDF") + + def test_html_report_contains_ai_summary_when_enabled(self): + from backend.secuscan.reporting import ReportGenerator + summary_text = "Risk is high. Address the admin panel exposure immediately." + with patch.object(ReportGenerator, "_get_ai_summary", return_value=summary_text): + html = ReportGenerator.generate_html_report(self._task(), self._result()) + assert "AI Executive Summary" in html + assert "Risk is high" in html + + def test_pdf_report_contains_ai_summary_when_enabled(self): + from backend.secuscan.reporting import ReportGenerator + summary_text = "Critical SQL injection found. Patch immediately." + with patch.object(ReportGenerator, "_get_ai_summary", return_value=summary_text): + html_src = ReportGenerator._generate_pdf_html_report(self._task(), self._result()) + assert "AI Executive Summary" in html_src + assert "Critical SQL injection found" in html_src + + def test_get_ai_summary_returns_empty_when_disabled(self): + from backend.secuscan.reporting import ReportGenerator + with patch.object(ReportGenerator, "_get_ai_summary", return_value="") as mock_method: + html = ReportGenerator.generate_html_report(self._task(), self._result()) + assert "AI Executive Summary" not in html + + def test_get_ai_summary_returns_empty_when_no_api_key(self): + from backend.secuscan.reporting import ReportGenerator + with patch("backend.secuscan.config.settings") as ms: + ms.ai_summary_enabled = True + ms.ai_summary_api_key = "" + result = ReportGenerator._get_ai_summary([{"title": "x", "severity": "high"}]) + assert result == "" + + def test_sarif_report_unchanged(self): + from backend.secuscan.reporting import ReportGenerator + import json + sarif_json = ReportGenerator.generate_sarif_report(self._task(), self._result()) + sarif = json.loads(sarif_json) + assert sarif["version"] == "2.1.0" + assert "ai_summary" not in str(sarif_json) diff --git a/testing/backend/unit/test_api_scanner_graphql.py b/testing/backend/unit/test_api_scanner_graphql.py new file mode 100644 index 000000000..a526bdbbc --- /dev/null +++ b/testing/backend/unit/test_api_scanner_graphql.py @@ -0,0 +1,53 @@ +from unittest.mock import AsyncMock + +import pytest + +from backend.secuscan.scanners.api_scanner import APIScanner + + +class MockResponse: + def __init__(self, status_code, headers=None, text=""): + self.status_code = status_code + self.headers = headers or {} + self.text = text + + +@pytest.mark.asyncio +async def test_graphql_404_response_not_classified_as_endpoint(): + scanner = APIScanner("test-task", None) + + client = AsyncMock() + client.options.return_value = MockResponse( + 404, + headers={"allow": "GET, POST"}, + ) + + findings, endpoints = await scanner._probe_graphql( + client, + "https://example.com", + allow_introspection=False, + ) + + assert endpoints == [] + assert findings == [] + + +@pytest.mark.asyncio +async def test_graphql_200_response_classified_as_endpoint(): + scanner = APIScanner("test-task", None) + + client = AsyncMock() + client.options.return_value = MockResponse( + 200, + headers={"allow": "GET, POST"}, + ) + + findings, endpoints = await scanner._probe_graphql( + client, + "https://example.com", + allow_introspection=False, + ) + + assert len(endpoints) > 0 + assert any(item["type"] == "graphql_endpoint" for item in endpoints) + assert any("GraphQL Endpoint Exposed" in item["title"] for item in findings) \ No newline at end of file diff --git a/testing/backend/unit/test_audit_policy.py b/testing/backend/unit/test_audit_policy.py new file mode 100644 index 000000000..7c756b98e --- /dev/null +++ b/testing/backend/unit/test_audit_policy.py @@ -0,0 +1,752 @@ +import sys +import os +import json +import yaml +import pytest +import datetime +from pathlib import Path +from unittest.mock import patch + +# Dynamically add scripts folder to python path +SCRIPTS_DIR = Path(__file__).resolve().parents[3] / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +# pyrefly: ignore [missing-import] +import check_pip_audit +# pyrefly: ignore [missing-import] +import check_npm_audit + +# ============================================================================== +# 1. UNIT TESTS: DATE AND EXCEPTION VALIDATION +# ============================================================================== + +def test_pip_is_exception_valid(): + """Test pip-audit is_exception_valid with various dates and formats""" + # Active timezone-aware UTC current time + current_utc = datetime.datetime.now(datetime.timezone.utc) + + # 1. Past date string (Expired) + exc_past = {"expires_at": "2025-01-01"} + assert check_pip_audit.is_exception_valid(exc_past, current_date=current_utc) is False + + # 2. Future date string (Valid) + exc_future = {"expires_at": "2035-12-31"} + assert check_pip_audit.is_exception_valid(exc_future, current_date=current_utc) is True + + # 3. Date object (Valid/Expired) + exc_date_past = {"expires_at": datetime.date(2025, 1, 1)} + assert check_pip_audit.is_exception_valid(exc_date_past, current_date=current_utc) is False + + exc_date_future = {"expires_at": datetime.date(2035, 12, 31)} + assert check_pip_audit.is_exception_valid(exc_date_future, current_date=current_utc) is True + + # 4. Datetime object + exc_dt_future = {"expires_at": datetime.datetime(2035, 12, 31, 23, 59, 59, tzinfo=datetime.timezone.utc)} + assert check_pip_audit.is_exception_valid(exc_dt_future, current_date=current_utc) is True + + # 5. Missing / Null fields + assert check_pip_audit.is_exception_valid({}, current_date=current_utc) is False + assert check_pip_audit.is_exception_valid({"expires_at": None}, current_date=current_utc) is False + +def test_npm_is_exception_valid(): + """Test npm-audit is_exception_valid with various dates and formats""" + current_utc = datetime.datetime.now(datetime.timezone.utc) + + # 1. Past date string + exc_past = {"expires_at": "2025-01-01"} + assert check_npm_audit.is_exception_valid(exc_past, current_date=current_utc) is False + + # 2. Future date string + exc_future = {"expires_at": "2035-12-31"} + assert check_npm_audit.is_exception_valid(exc_future, current_date=current_utc) is True + + # 3. Missing / Null fields + assert check_npm_audit.is_exception_valid({}, current_date=current_utc) is False + assert check_npm_audit.is_exception_valid({"expires_at": None}, current_date=current_utc) is False + +# ============================================================================== +# 2. UNIT TESTS: UPSTREAM JSON SHAPES AND EXTRACTION +# ============================================================================== + +def test_pip_parse_vulnerabilities_variations(): + """Test pip-audit parse_vulnerabilities with Variant A (Dictionary Wrapped) and Variant B (Flat List)""" + vulns_mock = [ + { + "id": "GHSA-c5u2-73g7-4w73", + "description": "Mock high severity vuln", + "severity": "HIGH" + } + ] + + # Variant A: Dictionary Wrapped + dict_report = { + "dependencies": [ + { + "name": "requests", + "version": "2.25.0", + "vulns": vulns_mock + } + ] + } + parsed_a = check_pip_audit.parse_vulnerabilities(dict_report) + assert len(parsed_a) == 1 + assert parsed_a[0]["package"] == "requests" + assert parsed_a[0]["cve"] == "GHSA-c5u2-73g7-4w73" + assert parsed_a[0]["vulnerability"]["severity"] == "HIGH" + + # Variant B: Flat List Array + list_report = [ + { + "name": "requests", + "version": "2.25.0", + "vulns": vulns_mock + } + ] + parsed_b = check_pip_audit.parse_vulnerabilities(list_report) + assert len(parsed_b) == 1 + assert parsed_b[0]["package"] == "requests" + assert parsed_b[0]["cve"] == "GHSA-c5u2-73g7-4w73" + assert parsed_b[0]["vulnerability"]["severity"] == "HIGH" + +def test_npm_extract_ghsa_or_cve(): + """Test npm-audit extract_ghsa_or_cve with different structures, specifically testing integer source IDs""" + # 1. Standard CWE list + issue_cwe = {"cwe": ["GHSA-c5u2-73g7-4w73"], "severity": "high"} + assert check_npm_audit.extract_ghsa_or_cve(issue_cwe) == "GHSA-c5u2-73g7-4w73" + + # 2. URL parsing + issue_url = {"url": "https://github.com/advisories/GHSA-1234-abcd-efgh", "severity": "high"} + assert check_npm_audit.extract_ghsa_or_cve(issue_url) == "GHSA-1234-abcd-efgh" + + # 3. Source string matching + issue_src_str = {"source": "GHSA-5678-ijkl-mnop", "severity": "high"} + assert check_npm_audit.extract_ghsa_or_cve(issue_src_str) == "GHSA-5678-ijkl-mnop" + + # 4. Edge case: Source integer mapping (advisory integer source ID) + issue_src_int = {"source": 1085097, "severity": "high"} + assert check_npm_audit.extract_ghsa_or_cve(issue_src_int) == "ADVISORY-1085097" + +# ============================================================================== +# 3. POLICY ENGINE INTEGRATION TESTS (FIXTURE BOUNDARIES) +# ============================================================================== + +@pytest.fixture +def base_config(): + """Returns a basic YAML config structure""" + return { + "version": 1.0, + "policy": { + "min_severity_to_block": "high", + "enforce_expiry": True, + "warn_before_expiry_days": 14, + }, + "exceptions": {}, + "excluded_packages": [], + } + +def test_empty_or_malformed_report_gate(tmp_path, base_config): + """Verify checkers fail (exit code 1) on missing, empty, or malformed JSON reports""" + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + # 1. Missing report + missing_report = tmp_path / "missing.json" + + # Pip audit + with patch("sys.argv", ["check_pip_audit.py", "--report", str(missing_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 1 + + # Npm audit + with patch("sys.argv", ["check_npm_audit.py", "--report", str(missing_report), "--config", str(config_file)]): + assert check_npm_audit.main() == 1 + + # 2. Empty report (0 bytes) + empty_report = tmp_path / "empty.json" + empty_report.touch() + + # Pip audit + with patch("sys.argv", ["check_pip_audit.py", "--report", str(empty_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 1 + + # Npm audit + with patch("sys.argv", ["check_npm_audit.py", "--report", str(empty_report), "--config", str(config_file)]): + assert check_npm_audit.main() == 1 + + # 3. Malformed report (invalid JSON) + malformed_report = tmp_path / "malformed.json" + with open(malformed_report, "w") as f: + f.write("{invalid_json: true}") + + # Pip audit + with patch("sys.argv", ["check_pip_audit.py", "--report", str(malformed_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 1 + + # Npm audit + with patch("sys.argv", ["check_npm_audit.py", "--report", str(malformed_report), "--config", str(config_file)]): + assert check_npm_audit.main() == 1 + +def test_expired_exception_fixture(tmp_path, base_config): + """Fixture 1: Verify expired exception causes build gate failure (exit code 1)""" + # Setup configuration with expired exception (expires_at: 2025-01-01) + base_config["exceptions"]["GHSA-c5u2-73g7-4w73"] = { + "package": "requests", + "expires_at": "2025-01-01", + "reason": "Expired exception test" + } + + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + # 1. Pip audit expired exception test + pip_report = tmp_path / "expired_exception_pip.json" + pip_data = { + "dependencies": [ + { + "name": "requests", + "version": "2.25.0", + "vulns": [ + { + "id": "GHSA-c5u2-73g7-4w73", + "severity": "HIGH", + "description": "High vulnerability" + } + ] + } + ] + } + with open(pip_report, "w") as f: + json.dump(pip_data, f) + + with patch("sys.argv", ["check_pip_audit.py", "--report", str(pip_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 1 + + # 2. Npm audit expired exception test + npm_report = tmp_path / "expired_exception_npm.json" + npm_data = { + "vulnerabilities": { + "requests": { + "name": "requests", + "severity": "high", + "via": [ + { + "source": "GHSA-c5u2-73g7-4w73", + "name": "requests", + "severity": "high", + "url": "https://github.com/advisories/GHSA-c5u2-73g7-4w73" + } + ] + } + } + } + with open(npm_report, "w") as f: + json.dump(npm_data, f) + + with patch("sys.argv", ["check_npm_audit.py", "--report", str(npm_report), "--config", str(config_file)]): + assert check_npm_audit.main() == 1 + + # 3. Verification of policy bypass flag: enforce_expiry = False + base_config["policy"]["enforce_expiry"] = False + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + with patch("sys.argv", ["check_pip_audit.py", "--report", str(pip_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 0 + + with patch("sys.argv", ["check_npm_audit.py", "--report", str(npm_report), "--config", str(config_file)]): + assert check_npm_audit.main() == 0 + +def test_blocking_severity_fixture(tmp_path, base_config): + """Fixture 2: Verify high severity advisory triggers gate failure when threshold is MEDIUM/MODERATE""" + # Allowed threshold set to medium/moderate (e.g. blocking anything >= medium) + base_config["policy"]["min_severity_to_block"] = "medium" + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + # 1a. Pip audit: CRITICAL (uppercase) vulnerability should fail + pip_report_upper = tmp_path / "blocking_severity_pip_upper.json" + pip_data_upper = { + "dependencies": [ + { + "name": "requests", + "version": "2.25.0", + "vulns": [ + { + "id": "GHSA-c5u2-73g7-4w73", + "severity": "CRITICAL", + "description": "Critical vulnerability" + } + ] + } + ] + } + with open(pip_report_upper, "w") as f: + json.dump(pip_data_upper, f) + + with patch("sys.argv", ["check_pip_audit.py", "--report", str(pip_report_upper), "--config", str(config_file)]): + assert check_pip_audit.main() == 1 + + # 1b. Pip audit: critical (lowercase) vulnerability should fail + pip_report_lower = tmp_path / "blocking_severity_pip_lower.json" + pip_data_lower = { + "dependencies": [ + { + "name": "requests", + "version": "2.25.0", + "vulns": [ + { + "id": "GHSA-c5u2-73g7-4w74", + "severity": "critical", + "description": "Critical vulnerability lowercase" + } + ] + } + ] + } + with open(pip_report_lower, "w") as f: + json.dump(pip_data_lower, f) + + with patch("sys.argv", ["check_pip_audit.py", "--report", str(pip_report_lower), "--config", str(config_file)]): + assert check_pip_audit.main() == 1 + + # 2. Npm audit: high vulnerability should fail + npm_report = tmp_path / "blocking_severity_npm.json" + npm_data = { + "vulnerabilities": { + "lodash": { + "name": "lodash", + "severity": "high", + "via": [ + { + "source": 1085097, + "name": "lodash", + "severity": "high", + "url": "https://github.com/advisories/GHSA-lodash-123" + } + ] + } + } + } + with open(npm_report, "w") as f: + json.dump(npm_data, f) + + with patch("sys.argv", ["check_npm_audit.py", "--report", str(npm_report), "--config", str(config_file)]): + assert check_npm_audit.main() == 1 + +def test_valid_exception_fixture(tmp_path, base_config): + """Fixture 3: Verify vulnerability with valid future exception returns clean exit code (0)""" + # Setup configuration with future exception (expires_at: 2035-12-31) + base_config["exceptions"]["GHSA-c5u2-73g7-4w73"] = { + "package": "requests", + "expires_at": "2035-12-31", + "reason": "Vulnerability monitored; valid future exception test" + } + + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + # 1. Pip audit valid exception + pip_report = tmp_path / "valid_exception_pip.json" + pip_data = { + "dependencies": [ + { + "name": "requests", + "version": "2.25.0", + "vulns": [ + { + "id": "GHSA-c5u2-73g7-4w73", + "severity": "HIGH", + "description": "High vulnerability" + } + ] + } + ] + } + with open(pip_report, "w") as f: + json.dump(pip_data, f) + + with patch("sys.argv", ["check_pip_audit.py", "--report", str(pip_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 0 + + # 2. Npm audit valid exception + npm_report = tmp_path / "valid_exception_npm.json" + npm_data = { + "vulnerabilities": { + "requests": { + "name": "requests", + "severity": "high", + "via": [ + { + "source": "GHSA-c5u2-73g7-4w73", + "name": "requests", + "severity": "high", + "url": "https://github.com/advisories/GHSA-c5u2-73g7-4w73" + } + ] + } + } + } + with open(npm_report, "w") as f: + json.dump(npm_data, f) + + with patch("sys.argv", ["check_npm_audit.py", "--report", str(npm_report), "--config", str(config_file)]): + assert check_npm_audit.main() == 0 + +def test_pip_alias_exception_matching(tmp_path, base_config): + """Test that pip exception matches by vulnerability alias (e.g. CVE ID)""" + # 1. Setup config with CVE exception + base_config["exceptions"]["CVE-2026-9999"] = { + "package": "pyyaml", + "expires_at": "2035-12-31", + "reason": "Alias matching test" + } + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + # 2. Setup pip report where the vuln ID is PYSEC-2026-9999, but aliases contains CVE-2026-9999 + pip_report = tmp_path / "alias_pip_report.json" + pip_data = { + "dependencies": [ + { + "name": "pyyaml", + "version": "6.0.0", + "vulns": [ + { + "id": "PYSEC-2026-9999", + "aliases": ["CVE-2026-9999", "GHSA-xxxx-xxxx-xxxx"], + "severity": "HIGH", + "description": "YAML deserialization" + } + ] + } + ] + } + with open(pip_report, "w") as f: + json.dump(pip_data, f) + + # Run check_pip_audit.py, it should succeed (exit code 0) since the alias is excepted + with patch("sys.argv", ["check_pip_audit.py", "--report", str(pip_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 0 + +def test_exception_expiry_warnings_and_bypass(tmp_path, base_config): + """Test exception close-to-expiry warning triggers, and enforce_expiry flag logic""" + # 1. Close-to-expiry warning test + current_utc = datetime.datetime.now(datetime.timezone.utc) + close_expiry_date = (current_utc + datetime.timedelta(days=5)).date().isoformat() + + exception = { + "package": "requests", + "expires_at": close_expiry_date, + "reason": "Soon expiring exception" + } + + # We should see warning printed or logged when checking expiry warning + with patch("check_pip_audit.logger.warning") as mock_warn: + check_pip_audit.check_expiry_warning(exception, warn_days=14, current_date=current_utc) + assert mock_warn.called + assert "expires in" in mock_warn.call_args[0][0] + + # 2. Exception expiry bypass when enforce_expiry is False + base_config["exceptions"]["CVE-2025-0001"] = { + "package": "requests", + "expires_at": "2020-01-01", # Definitely expired + "reason": "Expired but bypassed" + } + base_config["policy"]["enforce_expiry"] = False + + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + pip_report = tmp_path / "expired_pip_report.json" + pip_data = { + "dependencies": [ + { + "name": "requests", + "version": "2.25.0", + "vulns": [ + { + "id": "CVE-2025-0001", + "severity": "HIGH", + "description": "High vulnerability" + } + ] + } + ] + } + with open(pip_report, "w") as f: + json.dump(pip_data, f) + + with patch("sys.argv", ["check_pip_audit.py", "--report", str(pip_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 0 + +def test_blocking_severity_thresholds(tmp_path, base_config): + """Test blocking severity threshold case-insensitivity, unknown severity, and strict gating""" + # 1. Case-insensitivity test: severity "hIgH" or "CRITICAL" + base_config["policy"]["min_severity_to_block"] = "HiGh" + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + pip_report = tmp_path / "case_severity.json" + pip_data = { + "dependencies": [ + { + "name": "requests", + "version": "2.25.0", + "vulns": [ + { + "id": "CVE-2026-9999", + "severity": "hIgH", + "description": "High vuln" + } + ] + } + ] + } + with open(pip_report, "w") as f: + json.dump(pip_data, f) + + # hIgH should trigger high blocking severity and return exit code 1 + with patch("sys.argv", ["check_pip_audit.py", "--report", str(pip_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 1 + + # 2. Unknown severity: should not block when min-severity is high + pip_data_unknown = { + "dependencies": [ + { + "name": "requests", + "version": "2.25.0", + "vulns": [ + { + "id": "CVE-2026-9999", + "severity": "unknown", + "description": "Unknown severity" + } + ] + } + ] + } + with open(pip_report, "w") as f: + json.dump(pip_data_unknown, f) + + # unknown is below HiGh threshold, should pass (0) + with patch("sys.argv", ["check_pip_audit.py", "--report", str(pip_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 0 + + # 3. Unknown severity: should block when min-severity is low + base_config["policy"]["min_severity_to_block"] = "low" + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + # unknown is level 1, which blocks when min-severity is low + with patch("sys.argv", ["check_pip_audit.py", "--report", str(pip_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 1 + +def test_real_pip_audit_fixture_flow(tmp_path, base_config): + """Verify that parse_vulnerabilities parses real pip-audit flat list shape and main() blocks on high severity""" + fixture_path = Path(__file__).resolve().parents[0] / "fixtures" / "audit" / "pip_audit_real_shape.json" + with open(fixture_path) as f: + report_data = json.load(f) + + parsed = check_pip_audit.parse_vulnerabilities(report_data) + assert len(parsed) == 1 + assert parsed[0]["package"] == "requests" + assert parsed[0]["cve"] == "GHSA-c5u2-73g7-4w73" + assert parsed[0]["vulnerability"]["severity"] == "high" + + # Check that main() exits 1 when it contains a high issue + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + with patch("sys.argv", ["check_pip_audit.py", "--report", str(fixture_path), "--config", str(config_file)]): + assert check_pip_audit.main() == 1 + +def test_real_npm_audit_fixture_flow(tmp_path, base_config): + """Verify extract_ghsa_or_cve handles integer sources and that main() blocks on high severity from real npm shape""" + fixture_path = Path(__file__).resolve().parents[0] / "fixtures" / "audit" / "npm_audit_real_shape.json" + with open(fixture_path) as f: + report_data = json.load(f) + + # Check that it loads properly + vulnerabilities = report_data.get("vulnerabilities", {}) + assert "framer-motion" in vulnerabilities + via_list = vulnerabilities["framer-motion"].get("via", []) + assert len(via_list) == 1 + issue = via_list[0] + assert isinstance(issue["source"], int) + + cve_id = check_npm_audit.extract_ghsa_or_cve(issue) + # The URL contains the GHSA string + assert cve_id == "GHSA-jqrj-82ww" + + # Check that main() exits 1 when it contains a high issue + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + with patch("sys.argv", ["check_npm_audit.py", "--report", str(fixture_path), "--config", str(config_file)]): + assert check_npm_audit.main() == 1 + +def test_expired_exception_low_severity_no_block(tmp_path, base_config): + """Expired exception on a LOW severity vuln should NOT block when threshold=high""" + # Set exception for CVE-2025-LOW + base_config["exceptions"]["CVE-2025-LOW"] = { + "package": "somepkg", + "expires_at": "2020-01-01", # expired + "reason": "old" + } + base_config["policy"]["min_severity_to_block"] = "high" + + config_file = tmp_path / "config.yaml" + with open(config_file, "w") as f: + yaml.safe_dump(base_config, f) + + # Pip report with severity="low" + pip_report = tmp_path / "low_severity_expired_pip.json" + pip_data = { + "dependencies": [ + { + "name": "somepkg", + "version": "1.0.0", + "vulns": [ + { + "id": "CVE-2025-LOW", + "severity": "low", + "description": "Low severity vuln" + } + ] + } + ] + } + with open(pip_report, "w") as f: + json.dump(pip_data, f) + + with patch("sys.argv", ["check_pip_audit.py", "--report", str(pip_report), "--config", str(config_file)]): + assert check_pip_audit.main() == 0 + + # Npm report with severity="low" + npm_report = tmp_path / "low_severity_expired_npm.json" + npm_data = { + "vulnerabilities": { + "somepkg": { + "name": "somepkg", + "severity": "low", + "via": [ + { + "source": "CVE-2025-LOW", + "name": "somepkg", + "severity": "low", + "url": "https://github.com/advisories/CVE-2025-LOW" + } + ] + } + } + } + with open(npm_report, "w") as f: + json.dump(npm_data, f) + + with patch("sys.argv", ["check_npm_audit.py", "--report", str(npm_report), "--config", str(config_file)]): + assert check_npm_audit.main() == 0 + +# ============================================================================== +# 4. DIRECT UNIT TESTS: MALFORMED CONFIGS & REPORTS +# ============================================================================== + +def test_load_config_malformed_yaml(tmp_path): + """Test load_config function directly with malformed/invalid YAML""" + malformed_yaml_file = tmp_path / "malformed_config.yaml" + with open(malformed_yaml_file, "w") as f: + f.write("{invalid_yaml: [}") + + # check_pip_audit should catch yaml.YAMLError and return defaults + config_pip = check_pip_audit.load_config(str(malformed_yaml_file)) + assert isinstance(config_pip, dict) + assert config_pip["policy"]["min_severity_to_block"] == "high" + + # check_npm_audit should catch yaml.YAMLError and return defaults + config_npm = check_npm_audit.load_config(str(malformed_yaml_file)) + assert isinstance(config_npm, dict) + assert config_npm["policy"]["min_severity_to_block"] == "high" + +def test_load_config_missing_file(): + """Test load_config function directly with missing config file""" + missing_file_path = "non_existent_file_path_xyz.yaml" + + # check_pip_audit should return defaults + config_pip = check_pip_audit.load_config(missing_file_path) + assert isinstance(config_pip, dict) + assert config_pip["policy"]["min_severity_to_block"] == "high" + + # check_npm_audit should return defaults + config_npm = check_npm_audit.load_config(missing_file_path) + assert isinstance(config_npm, dict) + assert config_npm["policy"]["min_severity_to_block"] == "high" + +def test_load_report_malformed_json(tmp_path): + """Test load_report function directly with malformed JSON""" + malformed_json_file = tmp_path / "malformed_report.json" + with open(malformed_json_file, "w") as f: + f.write("{invalid_json: [}") + + # check_pip_audit.load_pip_audit_report should raise json.JSONDecodeError + with pytest.raises(json.JSONDecodeError): + check_pip_audit.load_pip_audit_report(str(malformed_json_file)) + + # check_npm_audit.load_npm_audit_report should raise json.JSONDecodeError + with pytest.raises(json.JSONDecodeError): + check_npm_audit.load_npm_audit_report(str(malformed_json_file)) + +def test_pip_parse_vulnerabilities_malformed_structure(): + """Test pip-audit parse_vulnerabilities directly with unexpected/malformed report structures""" + # 1. Report is completely empty dict + assert check_pip_audit.parse_vulnerabilities({}) == [] + + # 2. Report is an unexpected type (e.g. integer or string) + assert check_pip_audit.parse_vulnerabilities(42) == [] + assert check_pip_audit.parse_vulnerabilities("not a dict") == [] + + # 3. Report list has non-dict items + assert check_pip_audit.parse_vulnerabilities([None, 123, "string"]) == [] + + # 4. Report dict contains invalid dependencies (not list) + assert check_pip_audit.parse_vulnerabilities({"dependencies": "not a list"}) == [] + + # 5. Report list contains dicts with missing or malformed keys + bad_dep_report = [ + { + "name": "requests", + "version": "2.25.0", + "vulns": "not a list" # vulns key is not a list + } + ] + assert check_pip_audit.parse_vulnerabilities(bad_dep_report) == [] + + bad_vuln_report = [ + { + "name": "requests", + "version": "2.25.0", + "vulns": [None, "invalid_vuln_format"] # elements in list are not dicts + } + ] + assert check_pip_audit.parse_vulnerabilities(bad_vuln_report) == [] + +def test_npm_extract_ghsa_or_cve_malformed_structure(): + """Test npm-audit extract_ghsa_or_cve handles malformed structures and empty keys gracefully""" + # 1. Empty dict + assert check_npm_audit.extract_ghsa_or_cve({}) == "UNKNOWN" + + # 2. CWEs is not a list + assert check_npm_audit.extract_ghsa_or_cve({"cwe": "not a list"}) == "UNKNOWN" + + # 3. CWE list with non-string elements + assert check_npm_audit.extract_ghsa_or_cve({"cwe": [None, 123]}) == "UNKNOWN" + + # 4. URL or source field is not a string + assert check_npm_audit.extract_ghsa_or_cve({"url": 12345}) == "UNKNOWN" + assert check_npm_audit.extract_ghsa_or_cve({"source": []}) == "UNKNOWN" diff --git a/testing/backend/unit/test_auth_owner_resolution.py b/testing/backend/unit/test_auth_owner_resolution.py new file mode 100644 index 000000000..a1b1a9b23 --- /dev/null +++ b/testing/backend/unit/test_auth_owner_resolution.py @@ -0,0 +1,85 @@ +""" +Unit tests for auth.py owner-resolution helpers. + +Covers: resolve_owner_id, DEFAULT_OWNER_ID +""" + +from backend.secuscan.auth import resolve_owner_id, DEFAULT_OWNER_ID + + +# ── DEFAULT_OWNER_ID ────────────────────────────────────────────────────────── + + +def test_default_owner_id_value(): + assert DEFAULT_OWNER_ID == "default" + + +# ── resolve_owner_id ────────────────────────────────────────────────────────── + + +def test_resolve_owner_id_with_x_user_id_header(): + """X-User-Id header with value returns prefixed owner ID.""" + class MockRequest: + def __init__(self, headers): + self.headers = headers + + request = MockRequest({"x-user-id": "alice"}) + assert resolve_owner_id(request) == "user:alice" + + +def test_resolve_owner_id_trims_whitespace(): + """Leading/trailing whitespace in X-User-Id is stripped.""" + class MockRequest: + def __init__(self, headers): + self.headers = headers + + request = MockRequest({"x-user-id": " bob "}) + assert resolve_owner_id(request) == "user:bob" + + +def test_resolve_owner_id_whitespace_only(): + """Whitespace-only X-User-Id falls back to DEFAULT_OWNER_ID.""" + class MockRequest: + def __init__(self, headers): + self.headers = headers + + request = MockRequest({"x-user-id": " "}) + assert resolve_owner_id(request) == DEFAULT_OWNER_ID + + +def test_resolve_owner_id_empty_header(): + """Empty X-User-Id falls back to DEFAULT_OWNER_ID.""" + class MockRequest: + def __init__(self, headers): + self.headers = headers + + request = MockRequest({"x-user-id": ""}) + assert resolve_owner_id(request) == DEFAULT_OWNER_ID + + +def test_resolve_owner_id_missing_header(): + """Missing X-User-Id falls back to DEFAULT_OWNER_ID.""" + class MockRequest: + def __init__(self, headers): + self.headers = headers + + request = MockRequest({}) + assert resolve_owner_id(request) == DEFAULT_OWNER_ID + + +def test_resolve_owner_id_no_request(): + """None request falls back to DEFAULT_OWNER_ID.""" + assert resolve_owner_id(None) == DEFAULT_OWNER_ID + + +def test_resolve_owner_id_prefix_format(): + """Resolved owner ID always starts with 'user:' prefix.""" + class MockRequest: + def __init__(self, headers): + self.headers = headers + + for user_id in ["alice", "bob", "test-user-123", "UPPERCASE"]: + request = MockRequest({"x-user-id": user_id}) + result = resolve_owner_id(request) + assert result.startswith("user:"), f"failed for {user_id}" + assert result == f"user:{user_id.strip()}" diff --git a/testing/backend/unit/test_bulk_delete_sqlite_limit.py b/testing/backend/unit/test_bulk_delete_sqlite_limit.py index 6c1b8c504..8ac034078 100644 --- a/testing/backend/unit/test_bulk_delete_sqlite_limit.py +++ b/testing/backend/unit/test_bulk_delete_sqlite_limit.py @@ -186,9 +186,13 @@ async def test_single_chunk_does_not_exceed_sqlite_limit(self): mock_db = AsyncMock() mock_db.fetchall = AsyncMock(return_value=[]) + mock_db.fetchone = AsyncMock(return_value=None) + mock_db.begin = AsyncMock() + mock_db.commit = AsyncMock() + mock_db.rollback = AsyncMock() async def capture_execute(sql, params=()): captured_sql.append(sql) - mock_db.execute = capture_execute + mock_db.execute_no_commit = capture_execute with patch("backend.secuscan.routes.get_db", return_value=mock_db): await delete_task_records(ids) @@ -218,10 +222,14 @@ async def test_multi_chunk_splits_correctly(self): mock_db = AsyncMock() mock_db.fetchall = AsyncMock(return_value=[]) + mock_db.fetchone = AsyncMock(return_value=None) + mock_db.begin = AsyncMock() + mock_db.commit = AsyncMock() + mock_db.rollback = AsyncMock() async def capture_execute(sql, params=()): captured_sql.append(sql) captured_params.append(params) - mock_db.execute = capture_execute + mock_db.execute_no_commit = capture_execute with patch("backend.secuscan.routes.get_db", return_value=mock_db): await delete_task_records(ids) @@ -256,4 +264,9 @@ async def test_empty_list_returns_immediately(self): await delete_task_records([]) mock_db.execute.assert_not_called() + mock_db.execute_no_commit.assert_not_called() mock_db.fetchall.assert_not_called() + mock_db.fetchone.assert_not_called() + mock_db.begin.assert_not_called() + mock_db.commit.assert_not_called() + mock_db.rollback.assert_not_called() diff --git a/testing/backend/unit/test_config_settings.py b/testing/backend/unit/test_config_settings.py new file mode 100644 index 000000000..debc45cc1 --- /dev/null +++ b/testing/backend/unit/test_config_settings.py @@ -0,0 +1,166 @@ +""" +Unit tests for config.py Settings and parse_csv_or_list validator. + +Covers: Settings, parse_csv_or_list, resolved_vault_key, ensure_directories +""" + +import os +import tempfile +from pathlib import Path +from backend.secuscan.config import Settings, settings as global_settings + + +# ── parse_csv_or_list ───────────────────────────────────────────────────────── + + +def test_parse_csv_or_list_string(): + """Comma-separated string is split into a list.""" + result = Settings.parse_csv_or_list("1.1.1.1,2.2.2.2, 3.3.3.3 ") + assert result == ["1.1.1.1", "2.2.2.2", "3.3.3.3"] + + +def test_parse_csv_or_list_list_passthrough(): + """Already-a-list passes through unchanged.""" + original = ["a", "b", "c"] + result = Settings.parse_csv_or_list(original) + assert result == original + + +def test_parse_csv_or_list_whitespace(): + """Items with surrounding whitespace are stripped.""" + result = Settings.parse_csv_or_list(" alpha , beta ,gamma") + assert result == ["alpha", "beta", "gamma"] + + +def test_parse_csv_or_list_empty_string(): + """Empty string returns empty list.""" + result = Settings.parse_csv_or_list("") + assert result == [] + + +def test_parse_csv_or_list_single_item(): + """Single item without comma is returned as a single-element list.""" + result = Settings.parse_csv_or_list("only-one") + assert result == ["only-one"] + + +# ── Settings instantiation ───────────────────────────────────────────────────── + + +def test_settings_default_bind_address(): + """Default bind address is 127.0.0.1.""" + s = Settings() + assert s.bind_address == "127.0.0.1" + + +def test_settings_default_debug(): + """Debug defaults to True.""" + s = Settings() + assert s.debug is True + + +def test_settings_default_sandbox_disabled(): + """Docker sandbox is disabled by default.""" + s = Settings() + assert s.docker_enabled is False + + +def test_settings_env_override(): + """Field kwargs override defaults (SECUSCAN_ prefix used for env vars, not kwarg names).""" + s = Settings(bind_port=9999, debug=False) + assert s.bind_port == 9999 + assert s.debug is False + + +# ── resolved_vault_key ──────────────────────────────────────────────────────── + + +def test_resolved_vault_key_raises_without_key(): + """resolved_vault_key raises RuntimeError when neither vault_key nor plugin_signature_key is set.""" + s = Settings() + # Both vault_key and plugin_signature_key default to None + try: + s.resolved_vault_key + assert False, "Expected RuntimeError" + except RuntimeError as exc: + assert "SECUSCAN_VAULT_KEY" in str(exc) + + +def test_resolved_vault_key_uses_vault_key(): + """resolved_vault_key returns bytes derived from vault_key when set.""" + s = Settings(vault_key="my-secret-key") + key = s.resolved_vault_key + assert isinstance(key, bytes) + assert len(key) > 0 + + +def test_resolved_vault_key_uses_plugin_signature_key(): + """resolved_vault_key falls back to plugin_signature_key when vault_key is not set.""" + s = Settings(plugin_signature_key="another-secret") + key = s.resolved_vault_key + assert isinstance(key, bytes) + assert len(key) > 0 + + +# ── ensure_directories ──────────────────────────────────────────────────────── + + +def test_ensure_directories_creates_dirs(tmp_path): + """ensure_directories creates raw/reports/wordlists/knowledgebase dirs and log parent.""" + raw_dir = tmp_path / "raw" + reports_dir = tmp_path / "reports" + log_file = tmp_path / "logs" / "app.log" + + s = Settings( + raw_output_dir=str(raw_dir), + reports_dir=str(reports_dir), + log_file=str(log_file), + wordlists_dir=str(tmp_path / "wordlists"), + knowledgebase_dir=str(tmp_path / "kb"), + ) + s.ensure_directories() + + assert raw_dir.is_dir() + assert reports_dir.is_dir() + assert log_file.parent.is_dir() + + +def test_ensure_directories_creates_gitkeeps(tmp_path): + """ensure_directories creates .gitkeep files in raw and reports dirs.""" + s = Settings( + data_dir=str(tmp_path / "data"), + raw_output_dir=str(tmp_path / "raw"), + reports_dir=str(tmp_path / "reports"), + log_file=str(tmp_path / "logs" / "app.log"), + ) + s.ensure_directories() + + assert (tmp_path / "raw" / ".gitkeep").exists() + assert (tmp_path / "reports" / ".gitkeep").exists() + + +def test_ensure_directories_idempotent(tmp_path): + """ensure_directories is safe to call multiple times.""" + raw_dir = tmp_path / "raw" + reports_dir = tmp_path / "reports" + log_file = tmp_path / "logs" / "app.log" + + s = Settings( + raw_output_dir=str(raw_dir), + reports_dir=str(reports_dir), + log_file=str(log_file), + wordlists_dir=str(tmp_path / "wordlists"), + knowledgebase_dir=str(tmp_path / "kb"), + ) + s.ensure_directories() + s.ensure_directories() # must not raise + assert raw_dir.is_dir() + + +# ── base_url property ───────────────────────────────────────────────────────── + + +def test_base_url_property(): + """base_url returns the expected http://host:port string.""" + s = Settings(bind_address="0.0.0.0", bind_port=8080) + assert s.base_url == "http://0.0.0.0:8080" diff --git a/testing/backend/unit/test_crawler_helpers.py b/testing/backend/unit/test_crawler_helpers.py new file mode 100644 index 000000000..3616afd84 --- /dev/null +++ b/testing/backend/unit/test_crawler_helpers.py @@ -0,0 +1,479 @@ +""" +Unit tests for backend/secuscan/crawler.py helper functions. + +Covers the pure helpers exposed by the module: + - _build_headers + - _extract_title + - _classify_path_hint + - _extract_tech_hints + - _extract_cms_hints + - _normalize_form + +The crawl_target() function performs real HTTP I/O and is exercised by the +existing integration tests in testing/backend/test_crawler_plugin.py. The +helpers here are tested in isolation with synthetic inputs. +""" + +from __future__ import annotations + +import pytest + +from backend.secuscan.crawler import ( + _build_headers, + _classify_path_hint, + _extract_cms_hints, + _extract_tech_hints, + _extract_title, + _normalize_form, +) + + +# --------------------------------------------------------------------------- +# _build_headers +# --------------------------------------------------------------------------- + + +class TestBuildHeaders: + def test_default_user_agent(self): + headers = _build_headers() + assert headers["User-Agent"] == "SecuScan-Crawler/1.0" + + def test_default_accept_header(self): + headers = _build_headers() + assert "Accept" in headers + assert "text/html" in headers["Accept"] + + def test_extra_headers_override_defaults(self): + headers = _build_headers(extra_headers={"User-Agent": "Custom/2.0"}) + assert headers["User-Agent"] == "Custom/2.0" + + def test_extra_headers_merged(self): + headers = _build_headers(extra_headers={"X-Custom": "value"}) + assert headers["X-Custom"] == "value" + # Defaults are still present + assert "User-Agent" in headers + + def test_extra_headers_stringified(self): + # Keys and values must be coerced to str + headers = _build_headers(extra_headers={"X-Trace-Id": 12345}) + assert headers["X-Trace-Id"] == "12345" + assert isinstance(headers["X-Trace-Id"], str) + + def test_none_value_dropped(self): + headers = _build_headers(extra_headers={"X-Drop": None, "X-Keep": "ok"}) + assert "X-Drop" not in headers + assert headers["X-Keep"] == "ok" + + def test_empty_key_dropped(self): + headers = _build_headers(extra_headers={"": "value", "X-Keep": "ok"}) + assert "" not in headers + assert headers["X-Keep"] == "ok" + + def test_returns_dict(self): + assert isinstance(_build_headers(), dict) + assert isinstance(_build_headers(extra_headers={"a": "b"}), dict) + + def test_none_extra_headers_uses_defaults(self): + # Passing None should behave like no extra headers + headers = _build_headers(extra_headers=None) + assert headers["User-Agent"] == "SecuScan-Crawler/1.0" + + +# --------------------------------------------------------------------------- +# _extract_title +# --------------------------------------------------------------------------- + + +class TestExtractTitle: + def test_basic_title(self): + html = "<html><head><title>Hello World" + assert _extract_title(html) == "Hello World" + + def test_title_with_whitespace(self): + html = " Padded " + assert _extract_title(html) == "Padded" + + def test_missing_title_returns_empty(self): + html = "no title here" + assert _extract_title(html) == "" + + def test_only_closing_tag_returns_empty(self): + html = "random content with but no opening" + assert _extract_title(html) == "" + + def test_empty_title(self): + html = "" + assert _extract_title(html) == "" + + def test_title_with_attributes(self): + # Real-world pages may have attributes on + html = '<html><head><title lang="en">Attributed' + # The function uses a case-insensitive substring search for , + # so attributes inside the tag are NOT supported — opening tag must be plain. + # Verify the function handles the common plain case. + plain = "<title>Plain" + assert _extract_title(plain) == "Plain" + + def test_case_insensitive(self): + html = "Upper" + assert _extract_title(html) == "Upper" + + def test_multiline_html(self): + html = ( + "\n" + " \n" + " Multi\nLine\n" + " \n" + "" + ) + assert _extract_title(html) == "Multi\nLine" + + +# --------------------------------------------------------------------------- +# _classify_path_hint +# --------------------------------------------------------------------------- + + +class TestClassifyPathHint: + @pytest.mark.parametrize( + "path,expected", + [ + ("https://example.com/admin/login", "admin"), + ("https://example.com/administrator/", "admin"), + ("https://example.com/wp-admin/users", "admin"), + ("https://example.com/user/login", "login"), + ("https://example.com/auth/signin", "login"), + ("https://example.com/oauth/signin", "login"), + ("https://example.com/debug/status", "debug"), + ("https://example.com/actuator/health", "debug"), + ("https://example.com/_profiler", "debug"), + ("https://example.com/console", "debug"), + ("https://example.com/api/docs", "docs"), + ("https://example.com/swagger/ui", "docs"), + ("https://example.com/openapi.json", "docs"), + ("https://example.com/redoc", "docs"), + ], + ) + def test_classifies_known_categories(self, path, expected): + # _classify_path_hint expects a lowercased string per its docstring + assert _classify_path_hint(path.lower()) == expected + + def test_returns_none_for_unclassified(self): + assert _classify_path_hint("https://example.com/products/shoes") is None + assert _classify_path_hint("https://example.com/") is None + assert _classify_path_hint("https://example.com/blog/post-1") is None + + def test_handles_empty_string(self): + assert _classify_path_hint("") is None + + +# --------------------------------------------------------------------------- +# _extract_tech_hints +# --------------------------------------------------------------------------- + + +class TestExtractTechHints: + def test_x_powered_by_header(self): + hints = _extract_tech_hints({"X-Powered-By": "Express"}, [], [], "") + assert "Express" in hints + + def test_server_header(self): + hints = _extract_tech_hints({"Server": "nginx/1.24.0"}, [], [], "") + assert "nginx/1.24.0" in hints + + def test_x_generator_header(self): + hints = _extract_tech_hints({"X-Generator": "Drupal 9"}, [], [], "") + assert "Drupal 9" in hints + + def test_meta_generator_included(self): + hints = _extract_tech_hints({}, ["WordPress 6.4"], [], "") + assert "WordPress 6.4" in hints + + def test_wordpress_detected_from_body(self): + hints = _extract_tech_hints({}, [], [], "before /wp-content/themes/twenty/ after") + assert "WordPress" in hints + + def test_drupal_detected_from_body(self): + hints = _extract_tech_hints({}, [], [], '') + assert "Drupal" in hints + + def test_joomla_detected_from_body_string(self): + hints = _extract_tech_hints({}, [], [], "Welcome to Joomla!") + assert "Joomla" in hints + + def test_joomla_detected_from_body_media(self): + hints = _extract_tech_hints({}, [], [], '') + assert "Joomla" in hints + + def test_script_hints_for_react(self): + hints = _extract_tech_hints({}, [], ["https://example.com/static/react.production.min.js"], "") + assert "react.production.min.js" in hints + + def test_script_hints_for_vue(self): + hints = _extract_tech_hints({}, [], ["https://example.com/vue.runtime.js"], "") + assert "vue.runtime.js" in hints + + def test_script_hints_for_angular(self): + hints = _extract_tech_hints({}, [], ["https://example.com/angular.min.js"], "") + assert "angular.min.js" in hints + + def test_returns_sorted_unique(self): + headers = {"Server": "nginx"} + meta = ["WordPress"] + body = "wp-content here" + hints = _extract_tech_hints(headers, meta, [], body) + # The function sorts the result + assert hints == sorted(hints) + # And deduplicates + assert len(hints) == len(set(hints)) + + def test_empty_inputs(self): + hints = _extract_tech_hints({}, [], [], "") + assert hints == [] + + +# --------------------------------------------------------------------------- +# _extract_cms_hints +# --------------------------------------------------------------------------- + + +class TestExtractCmsHints: + def test_wordpress_from_meta(self): + hints = _extract_cms_hints(["WordPress 6.4"], "", []) + assert "wordpress" in hints + + def test_wordpress_from_body(self): + hints = _extract_cms_hints([], "", []) + assert "wordpress" in hints + + def test_drupal_from_meta(self): + hints = _extract_cms_hints(["Drupal 9"], "", []) + assert "drupal" in hints + + def test_drupal_from_body(self): + hints = _extract_cms_hints([], '', []) + assert "drupal" in hints + + def test_joomla_from_meta(self): + hints = _extract_cms_hints(["Joomla! 4"], "", []) + assert "joomla" in hints + + def test_joomla_from_scripts(self): + hints = _extract_cms_hints([], "", ["/media/system/js/mootools-core.js"]) + assert "joomla" in hints + + def test_multiple_cms_detected(self): + hints = _extract_cms_hints(["WordPress 6.4", "Joomla"], "wp-content /media/system/js/", []) + # Both should be detected; the function sorts and dedupes + assert "wordpress" in hints + assert "joomla" in hints + + def test_no_cms_returns_empty(self): + hints = _extract_cms_hints([], "nothing", []) + assert hints == [] + + def test_empty_inputs_returns_empty(self): + assert _extract_cms_hints([], "", []) == [] + + +# --------------------------------------------------------------------------- +# _normalize_form +# --------------------------------------------------------------------------- + + +class TestNormalizeForm: + PAGE_URL = "https://example.com/login" + + def test_action_urljoin_with_relative(self): + form = {"action": "submit", "method": "post", "inputs": []} + result = _normalize_form(self.PAGE_URL, form) + assert result["action"] == "https://example.com/submit" + + def test_action_urljoin_with_absolute(self): + form = {"action": "https://other.example/api", "method": "post", "inputs": []} + result = _normalize_form(self.PAGE_URL, form) + assert result["action"] == "https://other.example/api" + + def test_default_method_is_get(self): + form = {"action": "x", "inputs": []} + result = _normalize_form(self.PAGE_URL, form) + # The function only writes 'method' into the result when the form + # supplied one (falsy method is dropped) — so missing 'method' on the + # input means missing 'method' on the output. Verify the documented + # behaviour rather than assume a default is filled in. + assert "method" not in result + + def test_supplied_method_is_preserved(self): + # The function only uses method internally to decide state_changing; + # the original method string is preserved verbatim in the output dict. + form = {"action": "x", "method": "POST", "inputs": []} + result = _normalize_form(self.PAGE_URL, form) + assert result["method"] == "POST" + + def test_state_changing_post(self): + form = {"action": "x", "method": "post", "inputs": []} + result = _normalize_form(self.PAGE_URL, form) + assert result["state_changing"] is True + + def test_state_changing_put(self): + form = {"action": "x", "method": "PUT", "inputs": []} + result = _normalize_form(self.PAGE_URL, form) + assert result["state_changing"] is True + + def test_state_changing_patch(self): + form = {"action": "x", "method": "patch", "inputs": []} + result = _normalize_form(self.PAGE_URL, form) + assert result["state_changing"] is True + + def test_state_changing_delete(self): + form = {"action": "x", "method": "delete", "inputs": []} + result = _normalize_form(self.PAGE_URL, form) + assert result["state_changing"] is True + + def test_state_changing_due_to_password_input(self): + form = { + "action": "x", + "method": "get", + "inputs": [{"name": "pwd", "type": "password"}], + } + result = _normalize_form(self.PAGE_URL, form) + assert result["state_changing"] is True + + def test_state_changing_due_to_file_input(self): + form = { + "action": "x", + "method": "get", + "inputs": [{"name": "upload", "type": "file"}], + } + result = _normalize_form(self.PAGE_URL, form) + assert result["state_changing"] is True + + def test_state_changing_due_to_hidden_input(self): + form = { + "action": "x", + "method": "get", + "inputs": [{"name": "csrf_token", "type": "hidden"}], + } + result = _normalize_form(self.PAGE_URL, form) + assert result["state_changing"] is True + + def test_not_state_changing_get_with_text_input(self): + form = { + "action": "x", + "method": "get", + "inputs": [{"name": "q", "type": "text"}], + } + result = _normalize_form(self.PAGE_URL, form) + assert result["state_changing"] is False + + def test_password_fields_counted(self): + form = { + "action": "x", + "method": "post", + "inputs": [ + {"name": "user", "type": "text"}, + {"name": "pwd", "type": "password"}, + {"name": "pwd2", "type": "PASSWORD"}, + ], + } + result = _normalize_form(self.PAGE_URL, form) + assert result["password_fields"] == 2 + + def test_no_password_fields(self): + form = { + "action": "x", + "method": "post", + "inputs": [{"name": "user", "type": "text"}], + } + result = _normalize_form(self.PAGE_URL, form) + assert result["password_fields"] == 0 + + def test_input_count(self): + form = { + "action": "x", + "method": "post", + "inputs": [ + {"name": "a", "type": "text"}, + {"name": "b", "type": "text"}, + {"name": "c", "type": "text"}, + ], + } + result = _normalize_form(self.PAGE_URL, form) + assert result["input_count"] == 3 + + def test_csrf_token_detected(self): + form = { + "action": "x", + "method": "post", + "inputs": [ + {"name": "csrfmiddlewaretoken", "type": "hidden"}, + {"name": "user", "type": "text"}, + ], + } + result = _normalize_form(self.PAGE_URL, form) + assert result["has_csrf_token"] is True + + def test_csrf_token_detected_lowercased_name(self): + # csrf_names uses lowercase tokens; matching is on the lowercase form of the + # input name — verify the function lowercases input names before lookup. + form = { + "action": "x", + "method": "post", + "inputs": [{"name": "csrfmiddlewaretoken", "type": "hidden"}], + } + result = _normalize_form(self.PAGE_URL, form) + assert result["has_csrf_token"] is True + + def test_csrf_token_uppercase_name_not_detected(self): + # The lookup is on the lowercased name; "CSRFTOKEN" lowercases to + # "csrftoken" which is not in csrf_names — so it is correctly + # classified as not a CSRF token by this function. + form = { + "action": "x", + "method": "post", + "inputs": [{"name": "CSRFTOKEN", "type": "hidden"}], + } + result = _normalize_form(self.PAGE_URL, form) + assert result["has_csrf_token"] is False + + def test_csrf_token_not_detected_when_missing(self): + form = { + "action": "x", + "method": "post", + "inputs": [{"name": "user", "type": "text"}], + } + result = _normalize_form(self.PAGE_URL, form) + assert result["has_csrf_token"] is False + + def test_page_url_included(self): + form = {"action": "x", "method": "get", "inputs": []} + result = _normalize_form(self.PAGE_URL, form) + assert result["page_url"] == self.PAGE_URL + + def test_tolerates_missing_inputs(self): + form = {"action": "x", "method": "get"} + result = _normalize_form(self.PAGE_URL, form) + assert result["input_count"] == 0 + assert result["password_fields"] == 0 + assert result["state_changing"] is False + + def test_tolerates_non_list_inputs(self): + form = {"action": "x", "method": "get", "inputs": "not a list"} + result = _normalize_form(self.PAGE_URL, form) + assert result["input_count"] == 0 + + def test_tolerates_non_dict_input_items(self): + form = { + "action": "x", + "method": "post", + "inputs": ["not a dict", {"name": "user", "type": "text"}], + } + result = _normalize_form(self.PAGE_URL, form) + # Function should not crash; the valid dict is counted + assert result["input_count"] == 2 + + def test_blank_action_urljoined_to_page(self): + form = {"action": "", "method": "get", "inputs": []} + result = _normalize_form(self.PAGE_URL, form) + # Empty action resolves to the page URL itself + assert result["action"] == self.PAGE_URL diff --git a/testing/backend/unit/test_crawler_surface_parsing.py b/testing/backend/unit/test_crawler_surface_parsing.py new file mode 100644 index 000000000..fd6ec90ed --- /dev/null +++ b/testing/backend/unit/test_crawler_surface_parsing.py @@ -0,0 +1,84 @@ +""" +Genuinely-missing unit tests for crawler.py surface-parsing helpers. + +The bulk of crawler helper coverage lives in testing/backend/unit/test_crawler_helpers.py +(merged via PR #979). This file only contains cases that are NOT already covered +there, to avoid duplicate broad helper coverage. + +Currently covered here (and only here): + - _extract_title on a malformed/unclosed tag + - _extract_tech_hints deduplicates identical values across multiple headers + - _extract_cms_hints deduplicates identical CMS values from different sources + - _classify_path_hint returns 'login' for the '/user/login' variant + - _normalize_form with a non-standard method (DELETE) is state_changing + - _normalize_form when inputs contain a non-dict entry is tolerated +""" + +from backend.secuscan.crawler import ( + _classify_path_hint, + _extract_cms_hints, + _extract_tech_hints, + _extract_title, + _normalize_form, +) + + +# _extract_title — malformed input + + +def test_extract_title_unclosed_tag_returns_empty(): + # Opening <title> with no closing — the function must not crash + # and must return "" rather than matching some later "" in the + # document. + assert _extract_title("Never closed") == "" + + +# _extract_tech_hints — dedup across multiple header channels + + +def test_extract_tech_hints_dedupes_identical_values(): + # When the same value appears in both 'server' and 'x-powered-by' + # headers, the hint list must contain it only once. + headers = {"server": "Apache", "x-powered-by": "Apache"} + result = _extract_tech_hints(headers, [], [], "") + assert result.count("Apache") == 1 + + +# _extract_cms_hints — dedup across channels + + +def test_extract_cms_hints_dedupes_across_channels(): + # 'wordpress' detected from both meta and body should appear once. + result = _extract_cms_hints(["WordPress"], "wp-content here", []) + assert result.count("wordpress") == 1 + + +# _classify_path_hint — extra login path + + +def test_classify_path_hint_user_login(): + # The 'login' category uses /user/login as a token, which is not the same + # as /login alone. Verify the token is matched. + assert _classify_path_hint("https://example.com/user/login") == "login" + + +# _normalize_form — DELETE method and non-dict inputs entry + + +def test_normalize_form_delete_method_is_state_changing(): + form = {"method": "delete", "action": "/api/resource/1", "inputs": []} + result = _normalize_form("https://example.com", form) + assert result["state_changing"] is True + + +def test_normalize_form_tolerates_non_dict_input_item(): + # A non-dict item in the inputs list must not crash the function. + form = { + "method": "post", + "action": "/submit", + "inputs": ["not-a-dict", {"name": "user", "type": "text"}], + } + result = _normalize_form("https://example.com", form) + # The valid dict is still counted; the bad entry is skipped without raising. + assert result["input_count"] == 2 + assert result["state_changing"] is True diff --git a/testing/backend/unit/test_database_workflow_versions.py b/testing/backend/unit/test_database_workflow_versions.py new file mode 100644 index 000000000..15a5a0384 --- /dev/null +++ b/testing/backend/unit/test_database_workflow_versions.py @@ -0,0 +1,270 @@ +""" +Unit tests for database workflow version methods. +""" +import asyncio +import uuid +from backend.secuscan.database import Database + + +def run(coro): + return asyncio.run(coro) + + +def make_db(): + return Database(":memory:") + + +class TestSnapshotWorkflowVersion: + def test_first_snapshot_has_version_1(self): + db = make_db() + run(db.connect()) + try: + v = run(db.snapshot_workflow_version( + "wf-test-1", "Test WF", 60, True, [{"plugin_id": "nmap"}] + )) + assert v["version_number"] == 1 + assert v["workflow_id"] == "wf-test-1" + assert v["created_by"] == "system" + finally: + run(db.disconnect()) + + def test_subsequent_snapshots_increment_version(self): + db = make_db() + run(db.connect()) + try: + v1 = run(db.snapshot_workflow_version("wf-1", "WF", 60, True, [])) + v2 = run(db.snapshot_workflow_version("wf-1", "WF", 60, True, [])) + assert v2["version_number"] == v1["version_number"] + 1 + finally: + run(db.disconnect()) + + def test_snapshot_stores_definition(self): + db = make_db() + run(db.connect()) + try: + steps = [{"plugin_id": "nmap", "inputs": {"target": "127.0.0.1"}}] + v = run(db.snapshot_workflow_version("wf-1", "My WF", 120, False, steps)) + assert v["definition"]["name"] == "My WF" + assert v["definition"]["schedule_seconds"] == 120 + assert v["definition"]["enabled"] is False + assert v["definition"]["steps"] == steps + finally: + run(db.disconnect()) + + def test_snapshots_across_workflows_independent(self): + db = make_db() + run(db.connect()) + try: + v_a1 = run(db.snapshot_workflow_version("wf-A", "A", 60, True, [])) + v_b1 = run(db.snapshot_workflow_version("wf-B", "B", 60, True, [])) + v_a2 = run(db.snapshot_workflow_version("wf-A", "A", 60, True, [])) + assert v_a1["version_number"] == 1 + assert v_b1["version_number"] == 1 + assert v_a2["version_number"] == 2 + finally: + run(db.disconnect()) + + +class TestGetWorkflowVersions: + def test_returns_all_versions_newest_first(self): + db = make_db() + run(db.connect()) + try: + run(db.snapshot_workflow_version("wf-1", "WF", 60, True, [])) + run(db.snapshot_workflow_version("wf-1", "WF", 60, True, [])) + run(db.snapshot_workflow_version("wf-1", "WF", 60, True, [])) + versions = run(db.get_workflow_versions("wf-1")) + assert len(versions) == 3 + assert versions[0]["version_number"] == 3 + assert versions[1]["version_number"] == 2 + assert versions[2]["version_number"] == 1 + finally: + run(db.disconnect()) + + def test_returns_empty_for_unknown_workflow(self): + db = make_db() + run(db.connect()) + try: + versions = run(db.get_workflow_versions("does-not-exist")) + assert versions == [] + finally: + run(db.disconnect()) + + +class TestGetWorkflowVersion: + def test_returns_specific_version(self): + db = make_db() + run(db.connect()) + try: + created = run(db.snapshot_workflow_version("wf-1", "WF", 60, True, [])) + found = run(db.get_workflow_version("wf-1", created["version_number"])) + assert found is not None + assert found["id"] == created["id"] + finally: + run(db.disconnect()) + + def test_returns_none_for_missing_workflow(self): + db = make_db() + run(db.connect()) + try: + result = run(db.get_workflow_version("wf-does-not-exist", 99)) + assert result is None + finally: + run(db.disconnect()) + + def test_returns_none_for_missing_version_number(self): + db = make_db() + run(db.connect()) + try: + run(db.snapshot_workflow_version("wf-1", "WF", 60, True, [])) + result = run(db.get_workflow_version("wf-1", 99)) + assert result is None + finally: + run(db.disconnect()) + + +class TestRecordWorkflowRun: + def test_inserts_queued_run(self): + db = make_db() + run(db.connect()) + try: + run_id = run(db.record_workflow_run("wf-1", None, 1, ["t1", "t2"], "manual")) + assert run_id is not None + run_row = run(db.fetchone("SELECT status, triggered_by FROM workflow_runs WHERE id = ?", (run_id,))) + assert run_row["status"] == "queued" + assert run_row["triggered_by"] == "manual" + finally: + run(db.disconnect()) + + def test_inserts_empty_task_list(self): + db = make_db() + run(db.connect()) + try: + run_id = run(db.record_workflow_run("wf-1", None, 1, [], "scheduler")) + raw = run(db.fetchone("SELECT task_ids_json FROM workflow_runs WHERE id = ?", (run_id,))) + assert raw["task_ids_json"] == "[]" + finally: + run(db.disconnect()) + + +class TestFinalizeWorkflowRun: + def test_sets_status_and_timestamp(self): + db = make_db() + run(db.connect()) + try: + run_id = run(db.record_workflow_run("wf-1", None, 1, [], "manual")) + run(db.finalize_workflow_run(run_id, "completed")) + run_row = run(db.fetchone("SELECT status, completed_at FROM workflow_runs WHERE id = ?", (run_id,))) + assert run_row["status"] == "completed" + assert run_row["completed_at"] is not None + finally: + run(db.disconnect()) + + def test_finalize_with_error_message(self): + db = make_db() + run(db.connect()) + try: + run_id = run(db.record_workflow_run("wf-1", None, 1, [], "manual")) + run(db.finalize_workflow_run(run_id, "failed", error_message="Plugin not found")) + run_row = run(db.fetchone("SELECT status, error_message FROM workflow_runs WHERE id = ?", (run_id,))) + assert run_row["status"] == "failed" + assert run_row["error_message"] == "Plugin not found" + finally: + run(db.disconnect()) + + +class TestCheckWorkflowRunTasks: + def test_empty_run_returns_completed(self): + db = make_db() + run(db.connect()) + try: + run_id = run(db.record_workflow_run("wf-1", None, 1, [], "manual")) + result = run(db.check_workflow_run_tasks(run_id)) + assert result == "completed" + finally: + run(db.disconnect()) + + def test_all_tasks_completed_returns_completed(self): + db = make_db() + run(db.connect()) + try: + task_ids = [] + for _ in range(3): + tid = uuid.uuid4().hex + run(db.execute( + "INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, execution_context_json, status) VALUES (?, ?, ?, ?, ?, ?, ?)", + (tid, "nmap", "nmap", "127.0.0.1", "{}", "{}", "completed"), + )) + task_ids.append(tid) + run_id = run(db.record_workflow_run("wf-1", None, 1, task_ids, "manual")) + result = run(db.check_workflow_run_tasks(run_id)) + assert result == "completed" + finally: + run(db.disconnect()) + + def test_still_running_returns_none(self): + db = make_db() + run(db.connect()) + try: + tid = uuid.uuid4().hex + run(db.execute( + "INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, execution_context_json, status) VALUES (?, ?, ?, ?, ?, ?, ?)", + (tid, "nmap", "nmap", "127.0.0.1", "{}", "{}", "running"), + )) + run_id = run(db.record_workflow_run("wf-1", None, 1, [tid], "manual")) + result = run(db.check_workflow_run_tasks(run_id)) + assert result is None + finally: + run(db.disconnect()) + + def test_any_task_failed_returns_failed(self): + db = make_db() + run(db.connect()) + try: + tid = uuid.uuid4().hex + run(db.execute( + "INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, execution_context_json, status) VALUES (?, ?, ?, ?, ?, ?, ?)", + (tid, "nmap", "nmap", "127.0.0.1", "{}", "{}", "failed"), + )) + run_id = run(db.record_workflow_run("wf-1", None, 1, [tid], "manual")) + result = run(db.check_workflow_run_tasks(run_id)) + assert result == "failed" + finally: + run(db.disconnect()) + + def test_missing_run_id_returns_none(self): + db = make_db() + run(db.connect()) + try: + result = run(db.check_workflow_run_tasks("no-such-run")) + assert result is None + finally: + run(db.disconnect()) + + +class TestGetWorkflowRuns: + def test_returns_paginated_run_history(self): + db = make_db() + run(db.connect()) + try: + for _ in range(3): + run_id = run(db.record_workflow_run("wf-1", None, 1, [], "manual")) + run(db.finalize_workflow_run(run_id, "completed")) + result = run(db.get_workflow_runs("wf-1", limit=10)) + assert result["total"] == 3 + assert len(result["runs"]) == 3 + finally: + run(db.disconnect()) + + def test_respects_limit_and_offset(self): + db = make_db() + run(db.connect()) + try: + for _ in range(3): + run_id = run(db.record_workflow_run("wf-1", None, 1, [], "manual")) + run(db.finalize_workflow_run(run_id, "completed")) + result = run(db.get_workflow_runs("wf-1", limit=1, offset=1)) + assert result["total"] == 3 + assert len(result["runs"]) == 1 + finally: + run(db.disconnect()) diff --git a/testing/backend/unit/test_endpoint_rate_limiter.py b/testing/backend/unit/test_endpoint_rate_limiter.py index f45aa2223..e9fcabc18 100644 --- a/testing/backend/unit/test_endpoint_rate_limiter.py +++ b/testing/backend/unit/test_endpoint_rate_limiter.py @@ -275,6 +275,39 @@ def test_route_level_integration_and_independent_buckets(test_client, monkeypatc assert resp_vault1.headers["X-RateLimit-Remaining"] == "1" +def test_scheduler_tick_is_rate_limited(test_client, monkeypatch): + """The scheduler tick endpoint must throttle rapid repeated calls. + + Without a limiter, any API key holder could call the tick endpoint in a + tight loop and force continuous workflow execution. This test confirms the + limiter is wired: with a limit of 1, the second call within the window is + rejected with 429. + """ + from backend.secuscan import workflows + from backend.secuscan.ratelimit import scheduler_tick_limiter + + # Avoid any real scheduling side effects; only the limiter is under test. + async def _noop_tick(): + return None + + monkeypatch.setattr(workflows.scheduler, "tick", _noop_tick) + + # Tighten the limiter to one call per window for a deterministic assertion. + scheduler_tick_limiter.limit = 1 + scheduler_tick_limiter.window_seconds = 10 + asyncio.run(scheduler_tick_limiter.reset()) + + first = test_client.post("/api/v1/workflows/scheduler/tick") + assert first.status_code == 200 + assert first.json() == {"tick": "ok"} + assert first.headers["X-RateLimit-Limit"] == "1" + assert first.headers["X-RateLimit-Remaining"] == "0" + + second = test_client.post("/api/v1/workflows/scheduler/tick") + assert second.status_code == 429 + assert "Retry-After" in second.headers + + # ── RateLimiter per-client keying tests ─────────────────────────────────────── diff --git a/testing/backend/unit/test_execution_context.py b/testing/backend/unit/test_execution_context.py new file mode 100644 index 000000000..82bc71c88 --- /dev/null +++ b/testing/backend/unit/test_execution_context.py @@ -0,0 +1,157 @@ +""" +Unit tests for backend/secuscan/execution_context.py + +Covers: +- normalize_execution_context(ExecutionContext) round-trips the same data +- normalize_execution_context({}) returns ExecutionContext() defaults +- normalize_execution_context(None) returns ExecutionContext() defaults +- normalize_execution_context accepts an arbitrary dict and normalises it +- is_offensive_validation distinguishes detect_only from proof/controlled_extract +- is_offensive_validation defaults to PROOF (i.e. offensive) when no mode provided +- is_offensive_validation treats unknown validation modes as non-offensive +- evidence_level_rank returns 0/1/2 for minimal/standard/full +- evidence_level_rank defaults to 1 (standard) for unknown levels +- The original dict is not mutated by normalize_execution_context +""" + +from __future__ import annotations + +import pytest + +from backend.secuscan.execution_context import ( + evidence_level_rank, + is_offensive_validation, + normalize_execution_context, +) +from backend.secuscan.models import ( + EvidenceLevel, + ExecutionContext, + ValidationMode, +) + + +# --------------------------------------------------------------------------- +# normalize_execution_context +# --------------------------------------------------------------------------- + + +class TestNormalizeExecutionContext: + def test_execution_context_instance_round_trip(self): + """An ExecutionContext instance returns its model_dump.""" + original = ExecutionContext( + validation_mode=ValidationMode.PROOF, + evidence_level=EvidenceLevel.STANDARD, + ) + normalized = normalize_execution_context(original) + assert normalized["validation_mode"] == ValidationMode.PROOF.value + assert normalized["evidence_level"] == EvidenceLevel.STANDARD.value + + def test_empty_dict_returns_defaults(self): + """An empty dict is normalised to ExecutionContext() defaults.""" + normalized = normalize_execution_context({}) + # defaults from the ExecutionContext model + assert normalized["validation_mode"] == ValidationMode.PROOF.value + assert normalized["evidence_level"] == EvidenceLevel.STANDARD.value + + def test_none_returns_defaults(self): + """None input falls back to ExecutionContext() defaults.""" + normalized = normalize_execution_context(None) + assert normalized["validation_mode"] == ValidationMode.PROOF.value + assert normalized["evidence_level"] == EvidenceLevel.STANDARD.value + + def test_arbitrary_dict_is_normalised(self): + """An arbitrary dict is converted via ExecutionContext(**raw).""" + normalized = normalize_execution_context( + {"validation_mode": "detect_only", "evidence_level": "minimal"} + ) + assert normalized["validation_mode"] == "detect_only" + assert normalized["evidence_level"] == "minimal" + + def test_normalize_returns_dict(self): + """The return value is always a plain dict.""" + for value in (None, {}, {"validation_mode": "proof"}): + assert isinstance(normalize_execution_context(value), dict) + + def test_normalize_does_not_mutate_input(self): + """normalize_execution_context must not mutate the input dict.""" + original = {"validation_mode": "detect_only", "evidence_level": "minimal"} + snapshot = dict(original) + normalize_execution_context(original) + assert original == snapshot + + +# --------------------------------------------------------------------------- +# is_offensive_validation +# --------------------------------------------------------------------------- + + +class TestIsOffensiveValidation: + def test_detect_only_is_not_offensive(self): + assert is_offensive_validation({"validation_mode": "detect_only"}) is False + + def test_proof_is_offensive(self): + assert is_offensive_validation({"validation_mode": "proof"}) is True + + def test_controlled_extract_is_offensive(self): + assert is_offensive_validation({"validation_mode": "controlled_extract"}) is True + + def test_missing_validation_mode_defaults_to_offensive(self): + """No validation_mode key → defaults to PROOF → offensive.""" + assert is_offensive_validation({}) is True + + def test_none_validation_mode_defaults_to_offensive(self): + assert is_offensive_validation({"validation_mode": None}) is True + + def test_unknown_validation_mode_is_not_offensive(self): + """An unknown validation_mode is treated as non-offensive (safe default).""" + assert is_offensive_validation({"validation_mode": "exploit_everything"}) is False + + def test_empty_string_validation_mode_defaults_to_offensive(self): + """An empty-string validation_mode is treated like a missing value and + falls back to PROOF (i.e. offensive). The ``or`` operator in + ``is_offensive_validation`` deliberately uses truthiness, not a key + presence check, so the falsy empty string behaves like a missing key.""" + assert is_offensive_validation({"validation_mode": ""}) is True + + def test_extra_keys_are_ignored(self): + """Unrelated keys do not influence the offensive classification.""" + assert is_offensive_validation( + {"validation_mode": "detect_only", "evidence_level": "full"} + ) is False + assert is_offensive_validation( + {"validation_mode": "proof", "evidence_level": "minimal"} + ) is True + + +# --------------------------------------------------------------------------- +# evidence_level_rank +# --------------------------------------------------------------------------- + + +class TestEvidenceLevelRank: + def test_minimal_rank(self): + assert evidence_level_rank(EvidenceLevel.MINIMAL.value) == 0 + + def test_standard_rank(self): + assert evidence_level_rank(EvidenceLevel.STANDARD.value) == 1 + + def test_full_rank(self): + assert evidence_level_rank(EvidenceLevel.FULL.value) == 2 + + def test_ranks_are_strictly_ordered(self): + ranks = [ + evidence_level_rank(EvidenceLevel.MINIMAL.value), + evidence_level_rank(EvidenceLevel.STANDARD.value), + evidence_level_rank(EvidenceLevel.FULL.value), + ] + assert ranks == sorted(ranks) + assert len(set(ranks)) == 3 + + def test_unknown_level_defaults_to_standard(self): + """An unknown level falls back to the rank of standard (1).""" + assert evidence_level_rank("extreme") == 1 + assert evidence_level_rank("") == 1 + + def test_returns_int(self): + for level in ("minimal", "standard", "full", "unknown", ""): + assert isinstance(evidence_level_rank(level), int) diff --git a/testing/backend/unit/test_executor.py b/testing/backend/unit/test_executor.py index f9c887ae0..2db0b5e32 100644 --- a/testing/backend/unit/test_executor.py +++ b/testing/backend/unit/test_executor.py @@ -284,6 +284,7 @@ async def raise_cancelled(*args, **kwargs): "except asyncio.CancelledError handler is not writing to DB." ) mock_limiter.release.assert_called_once_with(task_id) + await db.disconnect() @pytest.mark.asyncio @@ -335,6 +336,8 @@ async def fake_command(*args, **kwargs): await executor.execute_task(task_id) mock_limiter.release.assert_called_once_with(task_id) + await db.disconnect() + def test_cancelled_error_is_not_subclass_of_exception(): @@ -344,3 +347,417 @@ def test_cancelled_error_is_not_subclass_of_exception(): in execute_task() needs revisiting. """ assert not issubclass(asyncio.CancelledError, Exception) + +# --------------------------------------------------------------------------- +# Executor-level network policy enforcement tests +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_execute_task_blocked_by_network_policy(setup_test_environment): + """ + When the network policy denies the task's target, execute_task() must: + - mark the task FAILED in the DB + - broadcast FAILED status + - never invoke the plugin/scanner + """ + await init_db(settings.database_path) + db = await get_db() + + task_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, + status, consent_granted, safe_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (task_id, "nmap", "nmap", "10.0.0.1", '{"target":"10.0.0.1"}', + TaskStatus.QUEUED.value, 1, 1) + ) + + executor = TaskExecutor() + + # Policy engine that always denies + mock_engine = MagicMock() + mock_engine.check_access.return_value = (False, "Blocked by denylist rule: test", None) + + with patch("backend.secuscan.executor.settings") as mock_settings, \ + patch("backend.secuscan.executor.get_policy_engine", return_value=mock_engine), \ + patch("backend.secuscan.executor.concurrent_limiter") as mock_limiter, \ + patch("backend.secuscan.executor.get_plugin_manager") as mock_pm: + + mock_settings.enforce_network_policy = True + mock_settings.docker_enabled = False + mock_settings.raw_output_dir = settings.raw_output_dir + mock_settings.sandbox_timeout = 600 + + mock_limiter.release = AsyncMock() + mock_pm.return_value.get_plugin.return_value = MagicMock(name="nmap", presets={}) + mock_pm.return_value.build_command.return_value = ["nmap", "10.0.0.1"] + + await executor.execute_task(task_id) + + row = await db.fetchone("SELECT status, error_message FROM tasks WHERE id = ?", (task_id,)) + assert row["status"] == TaskStatus.FAILED.value, ( + f"Expected FAILED, got {row['status']}" + ) + assert "Network policy denied" in (row["error_message"] or ""), ( + f"Expected denial reason in error_message, got: {row['error_message']}" + ) + # Plugin execution must not have been attempted + mock_pm.return_value.build_command.assert_not_called() + mock_limiter.release.assert_called_once_with(task_id) + await db.disconnect() + +@pytest.mark.asyncio +async def test_execute_task_allowed_by_network_policy(setup_test_environment): + """ + When the network policy allows the task's target, the plugin must execute normally. + """ + await init_db(settings.database_path) + db = await get_db() + + task_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, + status, consent_granted, safe_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (task_id, "nmap", "nmap", "8.8.8.8", '{"target":"8.8.8.8"}', + TaskStatus.QUEUED.value, 1, 0) + ) + + executor = TaskExecutor() + + mock_engine = MagicMock() + mock_engine.check_access.return_value = (True, "Allowed by allowlist rule: test", None) + + async def fake_command(*args, **kwargs): + return "80/tcp open http", 0 + + with patch("backend.secuscan.executor.settings") as mock_settings, \ + patch("backend.secuscan.executor.get_policy_engine", return_value=mock_engine), \ + patch.object(executor, "_execute_command", side_effect=fake_command), \ + patch("backend.secuscan.executor.concurrent_limiter") as mock_limiter, \ + patch("backend.secuscan.executor.get_plugin_manager") as mock_pm: + + mock_settings.enforce_network_policy = True + mock_settings.docker_enabled = False + mock_settings.raw_output_dir = settings.raw_output_dir + mock_settings.sandbox_timeout = 600 + + mock_limiter.release = AsyncMock() + + mock_plugin = MagicMock() + mock_plugin.name = "nmap" + mock_plugin.presets = {} + mock_plugin.docker_image = None + mock_plugin.output = {"parser": "builtin_nmap", "format": "text"} + mock_plugin.category = "Network" + mock_plugin.id = "nmap" + mock_pm.return_value.get_plugin.return_value = mock_plugin + mock_pm.return_value.build_command.return_value = ["nmap", "8.8.8.8"] + mock_pm.return_value.plugins_dir = MagicMock() + mock_pm.return_value.plugins_dir.__truediv__ = MagicMock( + return_value=MagicMock( + __truediv__=MagicMock(return_value=MagicMock(exists=lambda: False)) + ) + ) + + await executor.execute_task(task_id) + + row = await db.fetchone("SELECT status FROM tasks WHERE id = ?", (task_id,)) + assert row["status"] == TaskStatus.COMPLETED.value, ( + f"Expected COMPLETED, got {row['status']}" + ) + mock_engine.check_access.assert_called_once() + mock_limiter.release.assert_called_once_with(task_id) + await db.disconnect() + +@pytest.mark.asyncio +async def test_execute_task_network_policy_log_only(setup_test_environment): + """ + When settings.network_policy_failure_mode == "log_only", even if policy check + returns disallowed, the task execution should continue (and not mark the task as failed + due to network policy). + """ + await init_db(settings.database_path) + db = await get_db() + + task_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, + status, consent_granted, safe_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (task_id, "nmap", "nmap", "10.0.0.1", '{"target":"10.0.0.1"}', + TaskStatus.QUEUED.value, 1, 0) + ) + + executor = TaskExecutor() + + # Policy denies target + mock_engine = MagicMock() + mock_engine.check_access.return_value = (False, "Blocked by denylist rule: test", None) + + async def fake_command(*args, **kwargs): + return "80/tcp open http", 0 + + with patch("backend.secuscan.executor.settings") as mock_settings, \ + patch("backend.secuscan.executor.get_policy_engine", return_value=mock_engine), \ + patch.object(executor, "_execute_command", side_effect=fake_command), \ + patch("backend.secuscan.executor.concurrent_limiter") as mock_limiter, \ + patch("backend.secuscan.executor.get_plugin_manager") as mock_pm: + + mock_settings.enforce_network_policy = True + mock_settings.network_policy_failure_mode = "log_only" + mock_settings.docker_enabled = False + mock_settings.raw_output_dir = settings.raw_output_dir + mock_settings.sandbox_timeout = 600 + + mock_limiter.release = AsyncMock() + + mock_plugin = MagicMock() + mock_plugin.name = "nmap" + mock_plugin.presets = {} + mock_plugin.docker_image = None + mock_plugin.output = {"parser": "builtin_nmap", "format": "text"} + mock_plugin.category = "Network" + mock_plugin.id = "nmap" + mock_pm.return_value.get_plugin.return_value = mock_plugin + mock_pm.return_value.build_command.return_value = ["nmap", "10.0.0.1"] + mock_pm.return_value.plugins_dir = MagicMock() + mock_pm.return_value.plugins_dir.__truediv__ = MagicMock( + return_value=MagicMock( + __truediv__=MagicMock(return_value=MagicMock(exists=lambda: False)) + ) + ) + + await executor.execute_task(task_id) + + row = await db.fetchone("SELECT status FROM tasks WHERE id = ?", (task_id,)) + # Task should successfully complete because network violation is ignored in log_only mode! + assert row["status"] == TaskStatus.COMPLETED.value + mock_engine.check_access.assert_called_once() + await db.disconnect() + +@pytest.mark.asyncio +async def test_docker_network_autocreated_when_missing(setup_test_environment): + """If docker network is absent, executor auto-creates it and continues.""" + await init_db(settings.database_path) + db = await get_db() + + task_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, + status, consent_granted, safe_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (task_id, "nmap", "nmap", "8.8.8.8", '{"target":"8.8.8.8"}', + TaskStatus.QUEUED.value, 1, 0) + ) + + executor = TaskExecutor() + + # Policy allows the target so we reach the Docker block + mock_engine = MagicMock() + mock_engine.check_access.return_value = (True, "Allowed", None) + + call_count = 0 + async def fake_subprocess(*args, **kwargs): + nonlocal call_count + call_count += 1 + proc = MagicMock() + # First call: docker network inspect (fails, returncode=1) + # Second call: docker network create (succeeds, returncode=0) + proc.returncode = 1 if call_count == 1 else 0 + proc.stdout = AsyncMock(return_value=b"") + proc.stderr = AsyncMock(return_value=b"") + async def _wait(): + return proc.returncode + proc.wait = _wait + return proc + + # Stub the actually executed command to not actually run docker/nmap + async def fake_command(*args, **kwargs): + return "80/tcp open http", 0 + + with patch("backend.secuscan.executor.settings") as mock_settings, \ + patch("backend.secuscan.executor.get_policy_engine", return_value=mock_engine), \ + patch("backend.secuscan.executor.asyncio.create_subprocess_exec", side_effect=fake_subprocess), \ + patch.object(executor, "_execute_command", side_effect=fake_command), \ + patch("backend.secuscan.executor.concurrent_limiter") as mock_limiter, \ + patch("backend.secuscan.executor.get_plugin_manager") as mock_pm: + + mock_settings.enforce_network_policy = True + mock_settings.docker_enabled = True + mock_settings.docker_network = "restricted" + mock_settings.sandbox_memory_mb = 512 + mock_settings.sandbox_cpu_quota = 0.5 + mock_settings.sandbox_timeout = 600 + mock_settings.raw_output_dir = settings.raw_output_dir + + mock_limiter.release = AsyncMock() + + mock_plugin = MagicMock() + mock_plugin.name = "nmap" + mock_plugin.presets = {} + mock_plugin.docker_image = None + mock_plugin.output = {"parser": "builtin_nmap", "format": "text"} + mock_plugin.category = "Network" + mock_plugin.id = "nmap" + mock_pm.return_value.get_plugin.return_value = mock_plugin + mock_pm.return_value.build_command.return_value = ["nmap", "8.8.8.8"] + mock_pm.return_value.plugins_dir = MagicMock() + mock_pm.return_value.plugins_dir.__truediv__ = MagicMock( + return_value=MagicMock( + __truediv__=MagicMock(return_value=MagicMock(exists=lambda: False)) + ) + ) + + await executor.execute_task(task_id) + + row = await db.fetchone("SELECT status FROM tasks WHERE id = ?", (task_id,)) + # Should NOT be failed due to network - it was auto-created and completed + assert row["status"] == TaskStatus.COMPLETED.value + await db.disconnect() + +@pytest.mark.asyncio +async def test_docker_network_missing_and_create_fails(setup_test_environment): + """If docker network inspect AND create both fail, task is marked FAILED.""" + await init_db(settings.database_path) + db = await get_db() + + task_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, + status, consent_granted, safe_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (task_id, "nmap", "nmap", "8.8.8.8", '{"target":"8.8.8.8"}', + TaskStatus.QUEUED.value, 1, 0) + ) + + executor = TaskExecutor() + + # Policy allows the target so we reach the Docker block + mock_engine = MagicMock() + mock_engine.check_access.return_value = (True, "Allowed", None) + + # All subprocess calls (inspect, create isolated, create fallback) return returncode=1 + async def fake_subprocess(*args, **kwargs): + proc = MagicMock() + proc.returncode = 1 + proc.stdout = AsyncMock(return_value=b"") + proc.stderr = AsyncMock(return_value=b"") + async def _wait(): + return 1 + proc.wait = _wait + return proc + + with patch("backend.secuscan.executor.settings") as mock_settings, \ + patch("backend.secuscan.executor.get_policy_engine", return_value=mock_engine), \ + patch("backend.secuscan.executor.asyncio.create_subprocess_exec", side_effect=fake_subprocess), \ + patch("backend.secuscan.executor.concurrent_limiter") as mock_limiter, \ + patch("backend.secuscan.executor.get_plugin_manager") as mock_pm: + + mock_settings.enforce_network_policy = True + mock_settings.docker_enabled = True + mock_settings.docker_network = "restricted" + mock_settings.sandbox_memory_mb = 512 + mock_settings.sandbox_cpu_quota = 0.5 + mock_settings.sandbox_timeout = 600 + mock_settings.raw_output_dir = settings.raw_output_dir + + mock_limiter.release = AsyncMock() + + mock_plugin = MagicMock() + mock_plugin.name = "nmap" + mock_plugin.presets = {} + mock_plugin.docker_image = None + mock_pm.return_value.get_plugin.return_value = mock_plugin + mock_pm.return_value.build_command.return_value = ["nmap", "8.8.8.8"] + + await executor.execute_task(task_id) + + row = await db.fetchone("SELECT status, error_message FROM tasks WHERE id = ?", (task_id,)) + assert row["status"] == TaskStatus.FAILED.value + assert "does not exist and could not be created" in (row["error_message"] or "") + mock_limiter.release.assert_called_once_with(task_id) + await db.disconnect() + + +@pytest.mark.asyncio +async def test_execute_task_aborts_when_task_no_longer_queued(setup_test_environment): + """ + When the optimistic UPDATE ... WHERE status='queued' returns rowcount 0 + (because the task was deleted or its status changed before execution started), + execute_task() must abort without proceeding further. + """ + await init_db(settings.database_path) + db = await get_db() + + task_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, + status, consent_granted, safe_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (task_id, "nmap", "nmap", "127.0.0.1", '{"target":"127.0.0.1"}', + TaskStatus.RUNNING.value, 1, 1) + ) + + executor = TaskExecutor() + + with patch("backend.secuscan.executor.get_plugin_manager") as mock_pm, \ + patch("backend.secuscan.executor.concurrent_limiter") as mock_limiter: + mock_limiter.release = AsyncMock() + mock_pm.return_value.get_plugin.return_value = MagicMock(name="nmap", presets={}) + + await executor.execute_task(task_id) + + # Verify the task was NOT updated — it stays in its original (RUNNING) state + row = await db.fetchone("SELECT status FROM tasks WHERE id = ?", (task_id,)) + assert row["status"] == TaskStatus.RUNNING.value + mock_pm.return_value.build_command.assert_not_called() + await db.disconnect() + + +@pytest.mark.asyncio +async def test_execute_task_aborts_when_task_deleted_before_running(setup_test_environment): + """ + When the task row is deleted before execute_task runs the optimistic + UPDATE, the rowcount will be 0 and the method must abort gracefully. + """ + await init_db(settings.database_path) + db = await get_db() + + task_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, + status, consent_granted, safe_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (task_id, "nmap", "nmap", "127.0.0.1", '{"target":"127.0.0.1"}', + TaskStatus.QUEUED.value, 1, 1) + ) + + executor = TaskExecutor() + + with patch("backend.secuscan.executor.get_plugin_manager") as mock_pm, \ + patch("backend.secuscan.executor.concurrent_limiter") as mock_limiter: + mock_limiter.release = AsyncMock() + mock_pm.return_value.get_plugin.return_value = MagicMock(name="nmap", presets={}) + + # Delete the task before execute_task can update it + await db.execute("DELETE FROM tasks WHERE id = ?", (task_id,)) + + await executor.execute_task(task_id) + + assert task_id not in executor.running_tasks + await db.disconnect() diff --git a/testing/backend/unit/test_executor_notifications.py b/testing/backend/unit/test_executor_notifications.py new file mode 100644 index 000000000..f57304bf8 --- /dev/null +++ b/testing/backend/unit/test_executor_notifications.py @@ -0,0 +1,151 @@ +"""Executor integration with notification dispatch (PR 4 of #254).""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.database import get_db, init_db +from backend.secuscan.executor import TaskExecutor +from backend.secuscan.models import TaskStatus + + +@pytest.mark.asyncio +async def test_execute_task_dispatches_notifications_after_findings(setup_test_environment): + """Successful task completion should trigger process_task_notifications.""" + await init_db(settings.database_path) + db = await get_db() + + task_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, + status, consent_granted, safe_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + task_id, + "nmap", + "nmap", + "127.0.0.1", + '{"target":"127.0.0.1"}', + TaskStatus.QUEUED.value, + 1, + 1, + ), + ) + + executor = TaskExecutor() + + async def fake_command(*args, **kwargs): + return "80/tcp open http", 0 + + mock_dispatch = AsyncMock(return_value=[]) + + with ( + patch.object(executor, "_execute_command", side_effect=fake_command), + patch("backend.secuscan.executor.concurrent_limiter") as mock_limiter, + patch("backend.secuscan.executor.get_plugin_manager") as mock_pm, + patch( + "backend.secuscan.executor.process_task_notifications", + mock_dispatch, + ), + ): + mock_limiter.release = AsyncMock() + + mock_plugin = MagicMock() + mock_plugin.name = "nmap" + mock_plugin.presets = {} + mock_plugin.docker_image = None + mock_plugin.output = {"parser": "builtin_nmap", "format": "text"} + mock_plugin.category = "Network" + mock_plugin.id = "nmap" + mock_plugin.capabilities = [] + mock_plugin.safety = {"level": "safe"} + mock_pm.return_value.get_plugin.return_value = mock_plugin + mock_pm.return_value.build_command.return_value = ["nmap", "127.0.0.1"] + mock_pm.return_value.plugins_dir = MagicMock() + mock_pm.return_value.plugins_dir.__truediv__ = MagicMock( + return_value=MagicMock( + __truediv__=MagicMock(return_value=MagicMock(exists=lambda: False)) + ) + ) + + await executor.execute_task(task_id) + + mock_dispatch.assert_awaited_once() + assert mock_dispatch.await_args.args[1] == task_id + + row = await db.fetchone("SELECT status FROM tasks WHERE id = ?", (task_id,)) + assert row["status"] in (TaskStatus.COMPLETED.value, TaskStatus.FAILED.value) + await db.disconnect() + + +@pytest.mark.asyncio +async def test_execute_task_survives_notification_dispatch_failure(setup_test_environment): + """Notification errors must not prevent task completion.""" + await init_db(settings.database_path) + db = await get_db() + + task_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, inputs_json, + status, consent_granted, safe_mode) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + task_id, + "nmap", + "nmap", + "127.0.0.1", + '{"target":"127.0.0.1"}', + TaskStatus.QUEUED.value, + 1, + 1, + ), + ) + + executor = TaskExecutor() + + async def fake_command(*args, **kwargs): + return "80/tcp open http", 0 + + with ( + patch.object(executor, "_execute_command", side_effect=fake_command), + patch("backend.secuscan.executor.concurrent_limiter") as mock_limiter, + patch("backend.secuscan.executor.get_plugin_manager") as mock_pm, + patch( + "backend.secuscan.executor.process_task_notifications", + AsyncMock(side_effect=RuntimeError("webhook down")), + ), + ): + mock_limiter.release = AsyncMock() + + mock_plugin = MagicMock() + mock_plugin.name = "nmap" + mock_plugin.presets = {} + mock_plugin.docker_image = None + mock_plugin.output = {"parser": "builtin_nmap", "format": "text"} + mock_plugin.category = "Network" + mock_plugin.id = "nmap" + mock_plugin.capabilities = [] + mock_plugin.safety = {"level": "safe"} + mock_pm.return_value.get_plugin.return_value = mock_plugin + mock_pm.return_value.build_command.return_value = ["nmap", "127.0.0.1"] + mock_pm.return_value.plugins_dir = MagicMock() + mock_pm.return_value.plugins_dir.__truediv__ = MagicMock( + return_value=MagicMock( + __truediv__=MagicMock(return_value=MagicMock(exists=lambda: False)) + ) + ) + + await executor.execute_task(task_id) + + row = await db.fetchone( + "SELECT status, completed_at FROM tasks WHERE id = ?", (task_id,) + ) + assert row["completed_at"] is not None + assert row["status"] in (TaskStatus.COMPLETED.value, TaskStatus.FAILED.value) + await db.disconnect() diff --git a/testing/backend/unit/test_finding_intelligence.py b/testing/backend/unit/test_finding_intelligence.py new file mode 100644 index 000000000..749d018f1 --- /dev/null +++ b/testing/backend/unit/test_finding_intelligence.py @@ -0,0 +1,53 @@ +from backend.secuscan.finding_intelligence import build_finding_groups, build_scan_diff + + +def test_build_finding_groups_merges_duplicate_group_ids(): + findings = [ + { + "id": "finding-1", + "finding_group_id": "group:web:csp", + "title": "Missing Content-Security-Policy", + "severity": "medium", + "category": "Transport Security", + "target": "https://example.com", + "occurrence_count": 2, + "confidence": 0.82, + "corroborating_sources": ["crawl"], + }, + { + "id": "finding-2", + "finding_group_id": "group:web:csp", + "title": "Missing Content-Security-Policy", + "severity": "medium", + "category": "Transport Security", + "target": "https://example.com", + "occurrence_count": 3, + "confidence": 0.84, + "corroborating_sources": ["nuclei"], + }, + ] + + groups = build_finding_groups(findings) + + assert len(groups) == 1 + assert groups[0]["occurrence_count"] == 3 + assert set(groups[0]["corroborating_sources"]) == {"crawl", "nuclei"} + assert len(groups[0]["findings"]) == 2 + + +def test_build_scan_diff_tracks_new_resolved_and_changed_groups(): + current = [ + {"id": "new-1", "finding_group_id": "group:new", "title": "New finding", "severity": "high", "confidence": 0.9, "validated": False}, + {"id": "chg-2", "finding_group_id": "group:changed", "title": "Changed finding", "severity": "medium", "confidence": 0.8, "validated": True}, + ] + previous = [ + {"id": "old-1", "finding_group_id": "group:resolved", "title": "Resolved finding", "severity": "low", "confidence": 0.4, "validated": False}, + {"id": "chg-1", "finding_group_id": "group:changed", "title": "Changed finding", "severity": "low", "confidence": 0.3, "validated": False}, + ] + + diff = build_scan_diff(current, previous) + + assert diff["summary"] == {"new_count": 1, "resolved_count": 1, "changed_count": 1} + assert diff["new"][0]["id"] == "group:new" + assert diff["resolved"][0]["id"] == "group:resolved" + assert diff["changed"][0]["group_id"] == "group:changed" diff --git a/testing/backend/unit/test_knowledgebase.py b/testing/backend/unit/test_knowledgebase.py new file mode 100644 index 000000000..e3746a941 --- /dev/null +++ b/testing/backend/unit/test_knowledgebase.py @@ -0,0 +1,20 @@ +from backend.secuscan.knowledgebase import KnowledgeBase + + +def test_find_vulnerabilities_returns_exact_match_strength(): + kb = KnowledgeBase() + + result = kb.find_vulnerabilities(service="http", product="nginx", version="1.18.0") + + assert result["cpe"] == "cpe:/a:nginx:nginx:1.18.0" + assert result["match_strength"] == "exact" + assert result["cves"] + + +def test_find_vulnerabilities_returns_family_only_for_weak_match(): + kb = KnowledgeBase() + + result = kb.find_vulnerabilities(service="http", product="nginx", version="9.9.9") + + assert result["cpe"] == "cpe:/a:nginx:nginx:1.18.0" + assert result["match_strength"] == "family" diff --git a/testing/backend/unit/test_logging_utils.py b/testing/backend/unit/test_logging_utils.py new file mode 100644 index 000000000..c545f5255 --- /dev/null +++ b/testing/backend/unit/test_logging_utils.py @@ -0,0 +1,70 @@ +import json +import logging + +from backend.secuscan.logging_utils import RequestIDFilter, JSONFormatter + + +def test_request_id_filter_fallback(monkeypatch): + monkeypatch.setattr( + "backend.secuscan.logging_utils.get_request_id", + lambda: None, + ) + + record = logging.LogRecord( + name="test", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="hello", + args=(), + exc_info=None, + ) + + filt = RequestIDFilter() + assert filt.filter(record) is True + assert record.request_id == "no-request-id" + + +def test_json_formatter_serializes_log_record(): + record = logging.LogRecord( + name="test_logger", + level=logging.INFO, + pathname=__file__, + lineno=1, + msg="hello world", + args=(), + exc_info=None, + ) + record.request_id = "req-123" + + formatter = JSONFormatter() + result = json.loads(formatter.format(record)) + + assert result["level"] == "INFO" + assert result["logger"] == "test_logger" + assert result["message"] == "hello world" + assert result["request_id"] == "req-123" + assert "timestamp" in result + + +def test_json_formatter_serializes_exception(): + try: + raise ValueError("boom") + except ValueError: + exc_info = __import__("sys").exc_info() + + record = logging.LogRecord( + name="test_logger", + level=logging.ERROR, + pathname=__file__, + lineno=1, + msg="failure", + args=(), + exc_info=exc_info, + ) + + formatter = JSONFormatter() + result = json.loads(formatter.format(record)) + + assert "exception" in result + assert "ValueError" in result["exception"] \ No newline at end of file diff --git a/testing/backend/unit/test_network_policy.py b/testing/backend/unit/test_network_policy.py new file mode 100644 index 000000000..d72bd7c90 --- /dev/null +++ b/testing/backend/unit/test_network_policy.py @@ -0,0 +1,368 @@ +import pytest +import ipaddress +import tempfile +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import patch, MagicMock + +from backend.secuscan.network_policy import ( + NetworkPolicyEngine, NetworkPolicy, PolicyAction, AuditLogEntry, + get_policy_engine, _init_default_policies +) +from backend.secuscan.config import settings + +class TestDenyByDefault: + """Test deny-by-default behavior""" + + def test_empty_allowlist_denies_all(self, tmp_path): + """Engine with no allowlist should deny all""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + + allowed, reason, policy = engine.check_access( + dest_ip="8.8.8.8", + dest_port=53, + plugin_id="test", + ) + + assert not allowed + assert "denied by default" in reason.lower() + + def test_explicit_deny_blocks_immediately(self, tmp_path): + """Explicit denylist should block before checking allowlist""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + engine.add_deny_rule("10.0.0.0/8", reason="Internal network") + engine.add_allow_rule("10.0.0.0/8", reason="Oops, allowed it too") + + allowed, reason, policy = engine.check_access( + dest_ip="10.1.1.1", + plugin_id="test", + ) + + assert not allowed + assert "denylist" in reason.lower() + + +class TestInitDefaultPolicies: + """Test _init_default_policies logic""" + + def test_empty_allowlist_adds_default_public_egress(self, tmp_path): + """Empty allowlist should add implicit 0.0.0.0/0 and ::/0 rules for public egress""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + _init_default_policies(engine) + # The engine should have implicit allow-all rules for public egress + assert len(engine.allowlist) >= 2 + + def test_empty_allowlist_blocks_private_ranges(self, tmp_path): + """Even with implicit public egress, denylisted private ranges must be blocked""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + _init_default_policies(engine) + # Private/metadata IPs should still be blocked by denylist + for blocked_ip in ["10.0.0.1", "192.168.1.1", "172.16.0.1", + "169.254.169.254", "127.0.0.1", "100.64.0.1"]: + allowed, _, _ = engine.check_access(blocked_ip, plugin_id="test") + assert not allowed, f"{blocked_ip} should be blocked by denylist" + + def test_empty_allowlist_allows_public_ips(self, tmp_path): + """With implicit public egress, public IPs should be allowed""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + _init_default_policies(engine) + # Public IPs should be allowed + for public_ip in ["8.8.8.8", "1.1.1.1", "93.184.216.34"]: + allowed, _, _ = engine.check_access(public_ip, plugin_id="test") + assert allowed, f"{public_ip} should be allowed by default public egress" + + def test_explicit_allowlist_entries_are_loaded(self, monkeypatch, tmp_path): + """Entries in SECUSCAN_NETWORK_ALLOWLIST should appear in engine.allowlist""" + monkeypatch.setattr( + "backend.secuscan.config.settings.network_allowlist", + ["8.8.8.8/32", "1.1.1.1/32"], + ) + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + _init_default_policies(engine) + assert len(engine.allowlist) == 2 + + +class TestAllowlistPrecedence: + """Test allowlist matching""" + + def test_allowlist_permits_access(self, tmp_path): + """IP in allowlist should be permitted""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + engine.add_allow_rule("8.8.0.0/16", reason="Google DNS") + + allowed, reason, policy = engine.check_access( + dest_ip="8.8.8.8", + plugin_id="test", + ) + + assert allowed + assert "8.8.0.0/16" in reason + + def test_allowlist_subnet_matching(self, tmp_path): + """Allowlist should match subnets correctly""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + engine.add_allow_rule("192.0.2.0/24", reason="Test network") + + # In range + allowed, _, _ = engine.check_access("192.0.2.100", plugin_id="test") + assert allowed + + # Out of range + allowed, _, _ = engine.check_access("192.0.3.100", plugin_id="test") + assert not allowed + + +class TestDenylistPrecedence: + """Test denylist taking priority""" + + def test_denylist_overrides_allowlist(self, tmp_path): + """Denylist should override allowlist""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + engine.add_allow_rule("0.0.0.0/0", reason="Allow all") + engine.add_deny_rule("169.254.169.254/32", reason="AWS metadata") + + allowed, reason, _ = engine.check_access( + dest_ip="169.254.169.254", + plugin_id="test", + ) + + assert not allowed + assert "denylist" in reason.lower() + + def test_denylist_checked_before_allowlist(self, tmp_path): + """Denylist should be evaluated first for speed""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + engine.add_allow_rule("10.0.0.0/8", reason="Internal") + engine.add_deny_rule("10.1.0.0/16", reason="Restricted zone") + + # Should be denied despite being in allowlist + allowed, reason, _ = engine.check_access( + dest_ip="10.1.1.1", + plugin_id="test", + ) + + assert not allowed + + +class TestIPv6Support: + """Test IPv6 address handling""" + + def test_ipv6_allowlist(self, tmp_path): + """IPv6 addresses should be supported""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + engine.add_allow_rule("2001:4860::/32", reason="Google") + + allowed, _, _ = engine.check_access( + dest_ip="2001:4860:4860::8888", + plugin_id="test", + ) + + assert allowed + + def test_ipv6_denylist(self, tmp_path): + """IPv6 denylist should work""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + engine.add_deny_rule("fe80::/10", reason="Link-local") + + allowed, _, _ = engine.check_access( + dest_ip="fe80::1", + plugin_id="test", + ) + + assert not allowed + + +class TestAuditLogging: + """Test audit trail generation""" + + def test_audit_entry_on_allow(self, tmp_path): + """Allowed connections should be logged""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + engine.add_allow_rule("8.8.0.0/16", reason="Google DNS") + + engine.check_access( + dest_ip="8.8.8.8", + dest_port=53, + plugin_id="dns_enum", + task_id="task123", + ) + + assert len(engine.audit_entries) == 1 + entry = engine.audit_entries[0] + assert entry.action == PolicyAction.ALLOW + assert entry.dest_ip == "8.8.8.8" + assert entry.plugin_id == "dns_enum" + assert entry.task_id == "task123" + + def test_audit_entry_on_deny(self, tmp_path): + """Denied connections should be logged""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + + engine.check_access( + dest_ip="10.0.0.1", + dest_port=22, + plugin_id="port_scanner", + task_id="task456", + ) + + assert len(engine.audit_entries) == 1 + entry = engine.audit_entries[0] + assert entry.action == PolicyAction.DENY + assert entry.dest_ip == "10.0.0.1" + + def test_audit_log_file_written(self, tmp_path): + """Audit entries should be written to file""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + engine.add_allow_rule("8.8.0.0/16") + + engine.check_access("8.8.8.8", plugin_id="test") + + # Verify file contains JSON entry + content = audit_log.read_text() + assert "8.8.8.8" in content + assert "allow" in content + +class TestPolicyExpiration: + """Test temporary policies""" + + def test_expired_rule_not_evaluated(self, tmp_path): + """Expired rules should be skipped""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + + # Add rule that expires in the past + past = datetime.now() - timedelta(hours=1) + engine.add_allow_rule("10.0.0.0/8", expires_at=past) + + # Should be denied (rule expired) + allowed, _, _ = engine.check_access("10.1.1.1", plugin_id="test") + assert not allowed + + def test_future_rule_is_evaluated(self, tmp_path): + """Non-expired rules should be evaluated""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + + future = datetime.now() + timedelta(hours=1) + engine.add_allow_rule("10.0.0.0/8", expires_at=future) + + allowed, _, _ = engine.check_access("10.1.1.1", plugin_id="test") + assert allowed + +class TestInvalidInput: + """Test error handling""" + + def test_invalid_cidr_raises_error(self, tmp_path): + """Invalid CIDR should raise ValueError""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + + with pytest.raises(ValueError): + engine.add_allow_rule("not-a-valid-cidr", reason="test") + + def test_invalid_ip_denied(self, tmp_path): + """Invalid IP format should be denied""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + + allowed, reason, _ = engine.check_access( + dest_ip="not-an-ip", + plugin_id="test", + ) + + assert not allowed + assert "invalid" in reason.lower() + +class TestAuditLogFiltering: + """Test audit log queries""" + + def test_filter_by_plugin_id(self, tmp_path): + """Should filter audit entries by plugin""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + + engine.check_access("8.8.8.8", plugin_id="dns_enum", task_id="1") + engine.check_access("8.8.8.8", plugin_id="port_scanner", task_id="2") + + entries = engine.get_audit_entries(plugin_id="dns_enum") + assert len(entries) == 1 + assert entries[0].plugin_id == "dns_enum" + + def test_filter_by_action(self, tmp_path): + """Should filter audit entries by action""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + engine.add_allow_rule("8.8.0.0/16") + + engine.check_access("8.8.8.8", plugin_id="test") # ALLOW + engine.check_access("10.0.0.1", plugin_id="test") # DENY + + allow_entries = engine.get_audit_entries(action=PolicyAction.ALLOW) + assert len(allow_entries) == 1 + assert allow_entries[0].action == PolicyAction.ALLOW + +class TestURLTargetHandling: + """Test URL and target parsing/cleaning in the policy engine""" + + @patch("socket.gethostbyname") + def test_url_target_cleaning_and_resolution(self, mock_gethostbyname, tmp_path): + """URL hosts, ports, and brackets should be cleaned before matching policies""" + audit_log = tmp_path / "audit.log" + engine = NetworkPolicyEngine(audit_log_path=str(audit_log)) + engine.add_allow_rule("93.184.216.34/32", reason="Example IP") + + mock_gethostbyname.return_value = "93.184.216.34" + + # Check full URL + allowed, reason, policy = engine.check_access( + dest_ip="https://example.com/path?query=1", + plugin_id="test", + ) + assert allowed + mock_gethostbyname.assert_called_with("example.com") + + # Check host with port + allowed, reason, policy = engine.check_access( + dest_ip="example.com:8080", + plugin_id="test", + ) + assert allowed + + # Check IPv6 brackets cleaning + engine.add_allow_rule("::1/128", reason="IPv6 Loopback") + allowed, reason, policy = engine.check_access( + dest_ip="[::1]", + plugin_id="test", + ) + assert allowed + +class TestDefaultDenylistSSRFProtection: + """Test that private subnets are blocked by default in settings""" + + def test_private_subnets_in_default_denylist(self): + """Standard private ranges (RFC1918, RFC6598, IPv6 local) must be in the default denylist""" + from backend.secuscan.config import Settings + default_settings = Settings() + denylist = default_settings.network_denylist + assert "10.0.0.0/8" in denylist + assert "172.16.0.0/12" in denylist + assert "192.168.0.0/16" in denylist + assert "100.64.0.0/10" in denylist + assert "fc00::/7" in denylist + assert "fe80::/10" in denylist + assert "::1/128" in denylist diff --git a/testing/backend/unit/test_notification_service.py b/testing/backend/unit/test_notification_service.py new file mode 100644 index 000000000..826d0bd6f --- /dev/null +++ b/testing/backend/unit/test_notification_service.py @@ -0,0 +1,685 @@ +import json +import socket +import ssl +import uuid +from unittest.mock import AsyncMock, patch + +import httpx +import pytest +import pytest_asyncio + +from backend.secuscan import database as database_module +from backend.secuscan.config import settings +from backend.secuscan.database import init_db +from backend.secuscan.models import ( + NotificationChannelType, + NotificationDeliveryStatus, + NotificationSeverityThreshold, +) +from backend.secuscan.notification_service import ( + build_alert_payload, + deliver_via_rule, + process_finding_notifications, + severity_meets_threshold, + was_already_delivered, +) +from backend.secuscan.redaction import REDACTED + + +@pytest_asyncio.fixture +async def test_db(setup_test_environment): + db = await init_db(settings.database_path) + yield db + if database_module.db is not None: + await database_module.db.disconnect() + database_module.db = None + + +async def _seed_finding( + db, + *, + severity: str = "critical", + description: str = "Open port on target", +) -> tuple[str, str]: + task_id = str(uuid.uuid4()) + finding_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO tasks ( + id, plugin_id, tool_name, target, status, inputs_json, consent_granted + ) VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'completed', '{}', 1) + """, + (task_id,), + ) + await db.execute( + """ + INSERT INTO findings ( + id, task_id, plugin_id, title, category, severity, target, description, remediation + ) VALUES (?, ?, 'nmap', 'Open port', 'network', ?, '127.0.0.1', ?, 'fix') + """, + (finding_id, task_id, severity, description), + ) + return task_id, finding_id + + +async def _seed_rule( + db, + *, + severity_threshold: str = NotificationSeverityThreshold.HIGH.value, + channel_type: str = NotificationChannelType.WEBHOOK.value, + target: str = "https://example.com/hook", + is_active: int = 1, +) -> str: + rule_id = str(uuid.uuid4()) + await db.execute( + """ + INSERT INTO notification_rules ( + id, name, severity_threshold, channel_type, target_url_or_email, is_active + ) VALUES (?, 'Test rule', ?, ?, ?, ?) + """, + (rule_id, severity_threshold, channel_type, target, is_active), + ) + return rule_id + + +def test_severity_meets_threshold(): + assert severity_meets_threshold("critical", "high") is True + assert severity_meets_threshold("high", "high") is True + assert severity_meets_threshold("medium", "high") is False + assert severity_meets_threshold("info", "critical") is False + + +@pytest.mark.asyncio +async def test_build_alert_payload_redacts_secrets(): + finding = { + "id": "f1", + "task_id": "t1", + "plugin_id": "nmap", + "title": "Secret leak", + "category": "network", + "severity": "critical", + "target": "127.0.0.1", + "description": "Authorization: Bearer supersecrettoken12345678", + "remediation": "", + "metadata_json": json.dumps({"api_key": "abc123secret"}), + } + rule = { + "id": "r1", + "name": "Alerts", + "severity_threshold": "high", + "channel_type": "webhook", + } + + payload = build_alert_payload(finding, rule) + + assert REDACTED in payload["finding"]["description"] + assert "supersecrettoken12345678" not in payload["finding"]["description"] + assert payload["finding"]["metadata"]["api_key"] == REDACTED + + +@pytest.mark.asyncio +async def test_deliver_via_rule_sends_webhook_and_records_history(test_db): + _, finding_id = await _seed_finding(test_db) + rule_id = await _seed_rule(test_db) + + finding = await test_db.fetchone( + "SELECT * FROM findings WHERE id = ?", (finding_id,) + ) + rule = await test_db.fetchone( + "SELECT * FROM notification_rules WHERE id = ?", (rule_id,) + ) + + with patch( + "backend.secuscan.notification_service.send_webhook", + new=AsyncMock(return_value=(True, None)), + ): + result = await deliver_via_rule(test_db, rule, finding) + + assert result.status == NotificationDeliveryStatus.SUCCESS + assert result.skipped is False + assert await was_already_delivered(test_db, rule_id, finding_id) is True + + +@pytest.mark.asyncio +async def test_deliver_via_rule_dedupes_second_attempt(test_db): + _, finding_id = await _seed_finding(test_db) + rule_id = await _seed_rule(test_db) + + finding = await test_db.fetchone( + "SELECT * FROM findings WHERE id = ?", (finding_id,) + ) + rule = await test_db.fetchone( + "SELECT * FROM notification_rules WHERE id = ?", (rule_id,) + ) + + mock_send = AsyncMock(return_value=(True, None)) + with patch( + "backend.secuscan.notification_service.send_webhook", + new=mock_send, + ): + first = await deliver_via_rule(test_db, rule, finding) + second = await deliver_via_rule(test_db, rule, finding) + + assert first.status == NotificationDeliveryStatus.SUCCESS + assert second.skipped is True + assert mock_send.await_count == 1 + + +@pytest.mark.asyncio +async def test_deliver_skips_below_threshold(test_db): + _, finding_id = await _seed_finding(test_db, severity="low") + rule_id = await _seed_rule(test_db, severity_threshold="high") + + finding = await test_db.fetchone( + "SELECT * FROM findings WHERE id = ?", (finding_id,) + ) + rule = await test_db.fetchone( + "SELECT * FROM notification_rules WHERE id = ?", (rule_id,) + ) + + result = await deliver_via_rule(test_db, rule, finding) + + assert result.skipped is True + row = await test_db.fetchone( + "SELECT * FROM notification_history WHERE rule_id = ? AND finding_id = ?", + (rule_id, finding_id), + ) + assert row is None + + +@pytest.mark.asyncio +async def test_deliver_records_failure_on_webhook_error(test_db): + _, finding_id = await _seed_finding(test_db) + rule_id = await _seed_rule(test_db) + + finding = await test_db.fetchone( + "SELECT * FROM findings WHERE id = ?", (finding_id,) + ) + rule = await test_db.fetchone( + "SELECT * FROM notification_rules WHERE id = ?", (rule_id,) + ) + + with patch( + "backend.secuscan.notification_service.send_webhook", + new=AsyncMock(return_value=(False, "connection refused")), + ): + result = await deliver_via_rule(test_db, rule, finding) + + assert result.status == NotificationDeliveryStatus.FAILED + row = await test_db.fetchone( + "SELECT * FROM notification_history WHERE rule_id = ? AND finding_id = ?", + (rule_id, finding_id), + ) + assert row is not None + assert row["status"] == NotificationDeliveryStatus.FAILED.value + assert row["error_message"] == "connection refused" + + +@pytest.mark.asyncio +async def test_email_placeholder_records_success(test_db): + _, finding_id = await _seed_finding(test_db) + rule_id = await _seed_rule( + test_db, + channel_type=NotificationChannelType.EMAIL.value, + target="alerts@example.com", + ) + + results = await process_finding_notifications(test_db, finding_id) + + assert len(results) == 1 + assert results[0].status == NotificationDeliveryStatus.SUCCESS + assert results[0].skipped is False + + +def _mock_async_client(mock_post): + """Helper to mock httpx.AsyncClient as an async context manager.""" + mock_client = AsyncMock() + mock_client.post = mock_post + mock_cm = AsyncMock() + mock_cm.__aenter__.return_value = mock_client + return mock_cm + + +@pytest.mark.asyncio +async def test_send_webhook_success(): + """Normal webhook delivery succeeds.""" + from backend.secuscan.notification_service import send_webhook + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_post = AsyncMock(return_value=mock_response) + + def fake_success_addr(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("93.184.216.34", 443))] + + with ( + patch("httpx.AsyncClient", return_value=_mock_async_client(mock_post)), + patch( + "backend.secuscan.notification_service.socket.getaddrinfo", + side_effect=fake_success_addr, + ), + ): + ok, err = await send_webhook( + "https://hooks.example.com/alert", {"event": "test"} + ) + + assert ok is True + assert err is None + + +@pytest.mark.asyncio +async def test_send_webhook_http_error(): + """Webhook returning >=400 is reported as failure.""" + from backend.secuscan.notification_service import send_webhook + + mock_response = AsyncMock() + mock_response.status_code = 500 + mock_post = AsyncMock(return_value=mock_response) + + def fake_success_addr(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("93.184.216.34", 443))] + + with ( + patch("httpx.AsyncClient", return_value=_mock_async_client(mock_post)), + patch( + "backend.secuscan.notification_service.socket.getaddrinfo", + side_effect=fake_success_addr, + ), + ): + ok, err = await send_webhook( + "https://hooks.example.com/alert", {"event": "test"} + ) + + assert ok is False + assert "500" in err + + +@pytest.mark.asyncio +async def test_send_webhook_http_exception(): + """Transport-level errors are caught and returned as failure.""" + from backend.secuscan.notification_service import send_webhook + + mock_post = AsyncMock(side_effect=httpx.ConnectError("Connection refused")) + + def fake_success_addr(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("93.184.216.34", 443))] + + with ( + patch("httpx.AsyncClient", return_value=_mock_async_client(mock_post)), + patch( + "backend.secuscan.notification_service.socket.getaddrinfo", + side_effect=fake_success_addr, + ), + ): + ok, err = await send_webhook( + "https://hooks.example.com/alert", {"event": "test"} + ) + + assert ok is False + assert "Connection refused" in err + + +@pytest.mark.asyncio +async def test_send_webhook_blocks_private_ip_resolution(monkeypatch): + """Webhook to a hostname that resolves to a private IP must be rejected.""" + from backend.secuscan.notification_service import send_webhook + + def fake_getaddrinfo(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("10.0.0.5", 443))] + + monkeypatch.setattr( + "backend.secuscan.notification_service.socket.getaddrinfo", fake_getaddrinfo + ) + ok, err = await send_webhook("https://internal.example.com/hook", {"event": "test"}) + assert ok is False + assert "blocked" in err.lower() + + +@pytest.mark.asyncio +async def test_send_webhook_pins_connection_ip_for_https(monkeypatch): + """HTTPS webhook keeps the original hostname in the URL so that TLS SNI + and certificate verification operate against the expected name. The IP + pinning is done inside the custom transport, not via URL rewriting.""" + from backend.secuscan.notification_service import send_webhook + + def fake_getaddrinfo(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("93.184.216.34", 443))] + + monkeypatch.setattr( + "backend.secuscan.notification_service.socket.getaddrinfo", fake_getaddrinfo + ) + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_post = AsyncMock(return_value=mock_response) + mock_client = AsyncMock() + mock_client.post = mock_post + mock_cm = AsyncMock() + mock_cm.__aenter__.return_value = mock_client + + with patch("httpx.AsyncClient", return_value=mock_cm): + ok, err = await send_webhook( + "https://hooks.example.com/alert", {"event": "test"} + ) + + assert ok is True + call_args, call_kwargs = mock_post.call_args + posted_url = str(call_args[0]) if call_args else "" + # HTTPS must NOT rewrite the URL to the IP — doing so would break TLS. + assert "hooks.example.com" in posted_url, "HTTPS URL must keep original hostname" + assert "93.184.216.34" not in posted_url, ( + "HTTPS URL must NOT contain the resolved IP" + ) + + +@pytest.mark.asyncio +async def test_send_webhook_pins_connection_ip_for_http(monkeypatch): + """HTTP webhook rewrites the URL to the resolved IP and sets the Host + header. There is no TLS, so this is safe and prevents DNS rebinding.""" + from backend.secuscan.notification_service import send_webhook + + def fake_getaddrinfo(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("93.184.216.34", 80))] + + monkeypatch.setattr( + "backend.secuscan.notification_service.socket.getaddrinfo", fake_getaddrinfo + ) + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_post = AsyncMock(return_value=mock_response) + mock_client = AsyncMock() + mock_client.post = mock_post + mock_cm = AsyncMock() + mock_cm.__aenter__.return_value = mock_client + + with patch("httpx.AsyncClient", return_value=mock_cm): + ok, err = await send_webhook( + "http://hooks.example.com/alert", {"event": "test"} + ) + + assert ok is True + call_args, call_kwargs = mock_post.call_args + posted_url = str(call_args[0]) if call_args else "" + assert "93.184.216.34" in posted_url, ( + "HTTP request must go to resolved IP, not hostname" + ) + headers = call_kwargs.get("headers", {}) + assert headers.get("Host") == "hooks.example.com" + + +@pytest.mark.asyncio +async def test_send_webhook_blocks_metadata_ip_resolution(monkeypatch): + """Webhook to a hostname resolving to metadata IP must be rejected.""" + from backend.secuscan.notification_service import send_webhook + + def fake_getaddrinfo(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("169.254.169.254", 80))] + + monkeypatch.setattr( + "backend.secuscan.notification_service.socket.getaddrinfo", fake_getaddrinfo + ) + ok, err = await send_webhook("http://metadata.example.com/hook", {"event": "test"}) + assert ok is False + assert "blocked" in err.lower() + + +@pytest.mark.asyncio +async def test_send_webhook_rejects_unresolvable_hostname(monkeypatch): + """Unresolvable webhook hostname is reported as failure.""" + from backend.secuscan.notification_service import send_webhook + + def fake_getaddrinfo(*args, **kwargs): + raise OSError("Name or service not known") + + monkeypatch.setattr( + "backend.secuscan.notification_service.socket.getaddrinfo", fake_getaddrinfo + ) + ok, err = await send_webhook( + "https://nonexistent.example.invalid/hook", {"event": "test"} + ) + assert ok is False + assert "could not be resolved" in err.lower() + + +@pytest.mark.asyncio +async def test_send_webhook_ssrf_independent_of_enforce_network_policy(monkeypatch): + """SSRF protection must work even when enforce_network_policy is False.""" + from backend.secuscan.notification_service import send_webhook + from backend.secuscan.config import settings + + monkeypatch.setattr(settings, "enforce_network_policy", False) + + def fake_getaddrinfo(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("10.0.0.5", 443))] + + monkeypatch.setattr( + "backend.secuscan.notification_service.socket.getaddrinfo", fake_getaddrinfo + ) + ok, err = await send_webhook("https://internal.example.com/hook", {"event": "test"}) + assert ok is False + assert "blocked" in err.lower() + + +@pytest.mark.asyncio +async def test_send_webhook_https_uses_pinned_ip_transport(monkeypatch): + """HTTPS delivery creates _PinnedIPTransport with the validated IP and original hostname.""" + from backend.secuscan.notification_service import send_webhook, _PinnedIPTransport + + transport_args = {} + + def capture_transport(resolved_ip, original_hostname): + transport_args["resolved_ip"] = resolved_ip + transport_args["original_hostname"] = original_hostname + return _PinnedIPTransport(resolved_ip, original_hostname) + + monkeypatch.setattr( + "backend.secuscan.notification_service._PinnedIPTransport", + capture_transport, + ) + + def fake_getaddrinfo(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("93.184.216.34", 443))] + + monkeypatch.setattr( + "backend.secuscan.notification_service.socket.getaddrinfo", fake_getaddrinfo + ) + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_post = AsyncMock(return_value=mock_response) + + with patch( + "httpx.AsyncClient", + return_value=AsyncMock( + **{ + "post.return_value": mock_response, + "__aenter__.return_value.post.return_value": mock_response, + } + ), + ): + ok, err = await send_webhook( + "https://hooks.example.com/alert", {"event": "test"} + ) + + assert ok is True + assert transport_args.get("resolved_ip") == "93.184.216.34" + assert transport_args.get("original_hostname") == "hooks.example.com" + + +@pytest.mark.asyncio +async def test_send_webhook_redirect_to_blocked_ip(): + """Redirect to a private IP (SSRF) is rejected after delivery.""" + from backend.secuscan.notification_service import send_webhook + + mock_response = AsyncMock() + mock_response.status_code = 302 + mock_response.headers = {"location": "http://10.0.0.1/evil"} + mock_post = AsyncMock(return_value=mock_response) + + def fake_getaddrinfo(hostname, port=None, *args, **kwargs): + if "hooks.example.com" in hostname: + return [(socket.AF_INET, None, None, None, ("93.184.216.34", 443))] + # Redirect target resolves to private IP + return [(socket.AF_INET, None, None, None, ("10.0.0.1", 80))] + + with ( + patch("httpx.AsyncClient", return_value=_mock_async_client(mock_post)), + patch( + "backend.secuscan.notification_service.socket.getaddrinfo", + side_effect=fake_getaddrinfo, + ), + ): + ok, err = await send_webhook( + "https://hooks.example.com/alert", {"event": "test"} + ) + + assert ok is False + assert "blocked" in err.lower() + + +@pytest.mark.asyncio +async def test_send_webhook_https_delivery_pins_ip_and_preserves_tls_hostname( + monkeypatch, +): + """HTTPS webhook delivery through send_webhook connects TCP to the + validated (pinned) IP address while preserving the original hostname for + TLS SNI and certificate verification. + + This proves the full SSRF-prevention guarantee end-to-end: DNS resolution, + IP validation, custom transport creation, TCP-level IP pinning, and + hostname preservation for the TLS handshake. + """ + import httpcore._backends.auto as auto_backend + from backend.secuscan.notification_service import send_webhook + + tcp_connected_to = None + tls_sni_hostname = None + + class _TLSStream: + def __init__(self): + self._buffer = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 0\r\n" + b"Connection: close\r\n" + b"\r\n" + ) + + async def read(self, max_bytes, timeout=None): + if not self._buffer: + return b"" + chunk = self._buffer[:max_bytes] + self._buffer = self._buffer[max_bytes:] + return chunk + + async def write(self, buffer, timeout=None): + pass + + async def aclose(self): + pass + + async def start_tls(self, ssl_context, server_hostname=None, timeout=None): + nonlocal tls_sni_hostname + tls_sni_hostname = server_hostname + return self + + def get_extra_info(self, info): + return None + + async def _tracking_connect_tcp( + self, host, port, timeout=None, local_address=None, socket_options=None + ): + nonlocal tcp_connected_to + tcp_connected_to = host + return _TLSStream() + + monkeypatch.setattr( + auto_backend.AutoBackend, "connect_tcp", _tracking_connect_tcp + ) + + def fake_resolve(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("93.184.216.34", 443))] + + monkeypatch.setattr( + "backend.secuscan.notification_service.socket.getaddrinfo", fake_resolve + ) + + ok, err = await send_webhook( + "https://hooks.example.com/alert", {"event": "test"} + ) + + assert ok is True + assert err is None + assert tcp_connected_to == "93.184.216.34", ( + f"TCP must connect to validated/pinned IP (93.184.216.34), got {tcp_connected_to!r}" + ) + assert tls_sni_hostname == "hooks.example.com", ( + f"TLS SNI must use original hostname (hooks.example.com), got {tls_sni_hostname!r}" + ) + + +@pytest.mark.asyncio +async def test_pinned_ip_network_backend_pins_ip_and_preserves_tls_hostname( + monkeypatch, +): + """Regression test: _PinnedIPNetworkBackend connects TCP to the validated + (pinned) IP address, and _PinnedIPNetworkStream forces the original + hostname into start_tls for SNI / certificate verification. + + This is the core guarantee that prevents DNS-rebinding / TOCTOU attacks + without breaking HTTPS hostname verification. + """ + from backend.secuscan.notification_service import _PinnedIPNetworkBackend + + connected_host = None + tls_hostname = None + + class TrackingStream: + async def read(self, max_bytes, timeout=None): + return b"" + + async def write(self, buffer, timeout=None): + pass + + async def aclose(self): + pass + + async def start_tls(self, ssl_context, server_hostname=None, timeout=None): + nonlocal tls_hostname + tls_hostname = server_hostname + return self + + def get_extra_info(self, info): + return None + + import httpcore._backends.auto as auto_backend + + async def tracking_connect_tcp( + self, host, port, timeout=None, local_address=None, socket_options=None + ): + nonlocal connected_host + connected_host = host + return TrackingStream() + + monkeypatch.setattr(auto_backend.AutoBackend, "connect_tcp", tracking_connect_tcp) + + backend = _PinnedIPNetworkBackend( + resolved_ip="93.184.216.34", + original_hostname="hooks.example.com", + ) + + stream = await backend.connect_tcp(host="hooks.example.com", port=443) + + assert connected_host == "93.184.216.34", ( + f"TCP must connect to pinned IP (93.184.216.34), got {connected_host!r}" + ) + + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + await stream.start_tls( + ssl_context=ssl_ctx, + server_hostname="this-should-be-overridden.com", + ) + + assert tls_hostname == "hooks.example.com", ( + f"TLS SNI must use original hostname (hooks.example.com), got {tls_hostname!r}" + ) diff --git a/testing/backend/unit/test_platform_resources.py b/testing/backend/unit/test_platform_resources.py new file mode 100644 index 000000000..9a111ef68 --- /dev/null +++ b/testing/backend/unit/test_platform_resources.py @@ -0,0 +1,215 @@ +""" +Unit tests for backend/secuscan/platform_resources.py + +Covers the pure helpers exposed by the module: + - _stable_asset_id: deterministic asset id derivation + - _deserialize_resource_row: unwrap *_json columns + - deserialize_resource_rows: filter None rows + - serialize_execution_context: JSON round-trip with normalize_execution_context + +The persistence helpers (persist_crawl_run, replace_asset_services) require a +real Database and are not exercised here; they are covered by integration tests. +""" + +from __future__ import annotations + +import json + +import pytest + +from backend.secuscan.execution_context import normalize_execution_context +from backend.secuscan.platform_resources import ( + _deserialize_resource_row, + _stable_asset_id, + deserialize_resource_rows, + serialize_execution_context, +) + + +# --------------------------------------------------------------------------- +# _stable_asset_id +# --------------------------------------------------------------------------- + + +class TestStableAssetId: + def test_returns_string_starting_with_asset_prefix(self): + result = _stable_asset_id("example.com", "example.com", 443, "tcp") + assert isinstance(result, str) + assert result.startswith("asset:") + + def test_asset_id_is_16_hex_chars_after_prefix(self): + result = _stable_asset_id("example.com", "example.com", 443, "tcp") + suffix = result.split(":", 1)[1] + assert len(suffix) == 16 + # All hex characters + int(suffix, 16) + + def test_is_deterministic_for_same_inputs(self): + a = _stable_asset_id("example.com", "example.com", 443, "tcp") + b = _stable_asset_id("example.com", "example.com", 443, "tcp") + assert a == b + + def test_changes_when_target_changes(self): + a = _stable_asset_id("example.com", "example.com", 443, "tcp") + b = _stable_asset_id("other.com", "example.com", 443, "tcp") + assert a != b + + def test_changes_when_host_changes(self): + a = _stable_asset_id("example.com", "a.example.com", 443, "tcp") + b = _stable_asset_id("example.com", "b.example.com", 443, "tcp") + assert a != b + + def test_changes_when_port_changes(self): + a = _stable_asset_id("example.com", "example.com", 80, "tcp") + b = _stable_asset_id("example.com", "example.com", 443, "tcp") + assert a != b + + def test_changes_when_protocol_changes(self): + a = _stable_asset_id("example.com", "example.com", 443, "tcp") + b = _stable_asset_id("example.com", "example.com", 443, "udp") + assert a != b + + def test_handles_empty_fields(self): + # All-empty should still produce a valid asset id (no exception) + result = _stable_asset_id("", "", "", "") + assert result.startswith("asset:") + assert len(result.split(":", 1)[1]) == 16 + + def test_handles_none_fields(self): + # None values must be stringified, not raise + result = _stable_asset_id(None, None, None, None) + assert result.startswith("asset:") + assert len(result.split(":", 1)[1]) == 16 + + def test_lowercases_inputs(self): + a = _stable_asset_id("EXAMPLE.COM", "HOST", "443", "TCP") + b = _stable_asset_id("example.com", "host", "443", "tcp") + assert a == b + + def test_strips_whitespace(self): + a = _stable_asset_id(" example.com ", " host ", 443, "tcp") + b = _stable_asset_id("example.com", "host", 443, "tcp") + assert a == b + + def test_numeric_port_stringified_consistently(self): + # The function uses str(part).strip().lower() — ints and str(443) match + a = _stable_asset_id("example.com", "example.com", 443, "tcp") + b = _stable_asset_id("example.com", "example.com", "443", "tcp") + assert a == b + + +# --------------------------------------------------------------------------- +# _deserialize_resource_row +# --------------------------------------------------------------------------- + + +class TestDeserializeResourceRow: + def test_unwraps_json_suffix_column(self): + row = { + "id": "row-1", + "name": "row-1", + "summary_json": json.dumps({"x": 1}), + "pages_json": "[1, 2, 3]", + } + result = _deserialize_resource_row(row) + assert result["id"] == "row-1" + assert result["summary"] == {"x": 1} + assert result["pages"] == [1, 2, 3] + # The unwrapped keys live alongside the original *_json keys + assert "summary_json" in result + assert result["summary_json"] == '{"x": 1}' + + def test_preserves_non_json_columns(self): + row = {"id": "row-1", "name": "row-1", "owner_id": "user-1"} + result = _deserialize_resource_row(row) + assert result == {"id": "row-1", "name": "row-1", "owner_id": "user-1"} + + def test_tolerates_malformed_json_in_json_column(self): + row = {"id": "row-1", "summary_json": "{not valid json"} + result = _deserialize_resource_row(row) + # Malformed JSON falls back to the original raw string + assert result["summary"] == "{not valid json" + + def test_none_input_returns_none(self): + assert _deserialize_resource_row(None) is None + + def test_does_not_modify_input_row(self): + row = {"id": "row-1", "summary_json": json.dumps({"k": "v"})} + snapshot = dict(row) + _deserialize_resource_row(row) + assert row == snapshot + + def test_empty_json_string_unwraps_to_empty_dict_or_list(self): + # json.loads("") raises — must not crash, falls back to raw "" + row = {"id": "row-1", "summary_json": ""} + result = _deserialize_resource_row(row) + assert result["summary"] == "" + + +# --------------------------------------------------------------------------- +# deserialize_resource_rows +# --------------------------------------------------------------------------- + + +class TestDeserializeResourceRows: + def test_filters_none_rows(self): + rows = [None, {"id": "1", "name": "1"}, None, {"id": "2", "name": "2"}] + result = deserialize_resource_rows(rows) + assert len(result) == 2 + assert result[0]["id"] == "1" + assert result[1]["id"] == "2" + + def test_empty_list_returns_empty_list(self): + assert deserialize_resource_rows([]) == [] + + def test_unwraps_json_columns(self): + rows = [{"id": "1", "config_json": json.dumps({"k": 1})}] + result = deserialize_resource_rows(rows) + assert result[0]["config"] == {"k": 1} + # Original *_json column is preserved alongside the unwrapped key + assert result[0]["config_json"] == '{"k": 1}' + + def test_returns_list_of_dicts(self): + rows = [{"id": "1"}, {"id": "2"}] + result = deserialize_resource_rows(rows) + assert all(isinstance(item, dict) for item in result) + + +# --------------------------------------------------------------------------- +# serialize_execution_context +# --------------------------------------------------------------------------- + + +class TestSerializeExecutionContext: + def test_produces_valid_json(self): + result = serialize_execution_context({}) + # Must round-trip through json.loads + parsed = json.loads(result) + assert isinstance(parsed, dict) + + def test_matches_normalize_execution_context(self): + """The serialized payload must be the same as normalize_execution_context.""" + original = { + "validation_mode": "detect_only", + "evidence_level": "minimal", + } + serialized = serialize_execution_context(original) + expected = normalize_execution_context(original) + assert json.loads(serialized) == expected + + def test_handles_none(self): + result = serialize_execution_context(None) + # None must normalise to ExecutionContext() defaults + parsed = json.loads(result) + assert "validation_mode" in parsed + assert "evidence_level" in parsed + + def test_returns_string(self): + for value in (None, {}, {"validation_mode": "proof"}): + assert isinstance(serialize_execution_context(value), str) + + def test_empty_context_produces_full_default_payload(self): + parsed = json.loads(serialize_execution_context({})) + # The default ExecutionContext has at least these two fields + assert "validation_mode" in parsed + assert "evidence_level" in parsed diff --git a/testing/backend/unit/test_plugin_integrity.py b/testing/backend/unit/test_plugin_integrity.py index 89d94a6e3..44e39b7da 100644 --- a/testing/backend/unit/test_plugin_integrity.py +++ b/testing/backend/unit/test_plugin_integrity.py @@ -23,6 +23,24 @@ def test_plugins_have_checksums(): assert data.get("checksum"), f"Missing checksum in {path}" +def test_cli_plugins_declare_engine_binary_as_dependency(): + metadata_files = list(Path(settings.plugins_dir).glob("*/metadata.json")) + assert metadata_files, "Expected plugin metadata files" + + for path in metadata_files: + data = json.loads(path.read_text(encoding="utf-8")) + engine = data.get("engine", {}) + if engine.get("type") != "cli": + continue + + binary = engine.get("binary") + dependency_binaries = data.get("dependencies", {}).get("binaries", []) + assert binary in dependency_binaries, ( + f"{path.parent.name} must declare engine binary {binary!r} " + "in dependencies.binaries" + ) + + def test_plugin_metadata_ids_and_names_are_unique(): metadata_files = list(Path(settings.plugins_dir).glob("*/metadata.json")) assert metadata_files, "Expected plugin metadata files" diff --git a/testing/backend/unit/test_plugin_validator.py b/testing/backend/unit/test_plugin_validator.py index f344cd76c..0cdc69c27 100644 --- a/testing/backend/unit/test_plugin_validator.py +++ b/testing/backend/unit/test_plugin_validator.py @@ -26,6 +26,7 @@ VALID_SAFETY_LEVELS, VALID_FIELD_TYPES, VALID_PARSER_TYPES, + VALID_CATEGORIES, ) FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "plugins" @@ -48,7 +49,7 @@ def _error_messages(result: ValidationResult) -> list[str]: def _write_metadata(tmp_path: Path, data: dict) -> Path: plugin_dir = tmp_path / "my_plugin" - plugin_dir.mkdir() + plugin_dir.mkdir(exist_ok=True) (plugin_dir / "metadata.json").write_text(json.dumps(data), encoding="utf-8") return plugin_dir @@ -65,8 +66,8 @@ def _minimal_valid() -> dict: "engine": {"type": "cli", "binary": "ping"}, "command_template": ["ping", "-c", "{count}", "{target}"], "fields": [ - {"id": "target", "label": "Target Host", "type": "text"}, - {"id": "count", "label": "Count", "type": "number"}, + {"id": "target", "label": "Target Host", "type": "text", "help": "IP address or hostname"}, + {"id": "count", "label": "Count", "type": "number", "help": "Number of packets"}, ], "output": {"parser": "text"}, "safety": {"level": "safe", "requires_consent": False}, @@ -135,6 +136,11 @@ def test_invalid_fixture_catches_unknown_placeholder(self): placeholder_errors = [e for e in result.errors if "Placeholder" in e.message] assert placeholder_errors, "Expected placeholder-mismatch error" + def test_invalid_fixture_catches_missing_help_text(self): + result = validate_one_plugin(INVALID_FIXTURE) + help_warnings = [e for e in result.warnings if e.path.endswith(".help")] + assert len(help_warnings) >= 2, "Expected help text warnings for both fields" + # =========================================================================== # Required fields @@ -536,4 +542,61 @@ def test_error_display_format(self, tmp_path): result = validate_one_plugin(plugin_dir) err = next(e for e in result.errors if e.path == "safety.level") display = err.display() - assert "[" in display and "safety.level" in display and "→" in display \ No newline at end of file + assert "[" in display and "safety.level" in display and "→" in display + + +# =========================================================================== +# Metadata quality lint checks +# =========================================================================== + + +class TestMetadataQualityLint: + def test_missing_field_help_text_reported_as_warning(self, tmp_path): + data = _minimal_valid() + data["fields"] = [ + {"id": "target", "label": "Target", "type": "text"}, + ] + data["command_template"] = ["ping", "{target}"] + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + help_warnings = [e for e in result.warnings if e.path == "fields[0].help"] + assert len(help_warnings) == 1 + assert "help" in help_warnings[0].message + + def test_field_help_text_present_no_warning(self, tmp_path): + data = _minimal_valid() + data["fields"] = [ + {"id": "target", "label": "Target", "type": "text", "help": "The target IP or hostname"}, + ] + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + help_warnings = [e for e in result.warnings if e.path.startswith("fields[0].help")] + assert help_warnings == [] + + def test_invalid_category_reported(self, tmp_path): + data = _minimal_valid() + data["category"] = "unknown_category" + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + assert "category" in _error_paths(result) + cat_errors = [e for e in result.errors if e.path == "category"] + assert len(cat_errors) == 1 + assert "not a recognized category" in cat_errors[0].message + + def test_valid_categories_accepted(self, tmp_path): + for cat in sorted(VALID_CATEGORIES): + data = _minimal_valid() + data["category"] = cat + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + cat_errors = [e for e in result.errors if e.path == "category"] + assert cat_errors == [], f"Category '{cat}' should be valid" + + def test_missing_category_is_not_flagged(self, tmp_path): + data = _minimal_valid() + del data["category"] + plugin_dir = _write_metadata(tmp_path, data) + result = validate_one_plugin(plugin_dir) + cat_errors = [e for e in result.errors if e.path == "category"] + assert len(cat_errors) == 1 + assert "Required" in cat_errors[0].message diff --git a/testing/backend/unit/test_plugins.py b/testing/backend/unit/test_plugins.py index 45e5e6aca..4cd2c56a9 100644 --- a/testing/backend/unit/test_plugins.py +++ b/testing/backend/unit/test_plugins.py @@ -45,6 +45,35 @@ def test_plugin_manager_build_command(setup_test_environment): assert "http://127.0.0.1" in command +def test_plugin_interpolation_sanitizes_user_controlled_values(): + manager = PluginManager("plugins") + + assert manager._interpolate("{templates}", {"templates": "--debug;$(whoami)"}) == "debugwhoami" + assert ( + manager._interpolate("--user-agent={user_agent}", {"user_agent": "--verbose|curl"}) + == "--user-agent=verbosecurl" + ) + + +def test_plugin_interpolation_preserves_legitimate_argv_values(): + manager = PluginManager("plugins") + + assert ( + manager._interpolate( + "--url={target}", + {"target": "https://api-v1.example.com:8443/health-check"}, + ) + == "--url=https://api-v1.example.com:8443/health-check" + ) + assert ( + manager._interpolate( + "--user-agent={user_agent}", + {"user_agent": "SecuScan-CLI/1.0 api-health-check"}, + ) + == "--user-agent=SecuScan-CLI/1.0 api-health-check" + ) + + def test_plugin_list_exposes_runtime_capabilities(setup_test_environment, monkeypatch): """Plugin list payload includes consent and availability details.""" manager = PluginManager(settings.plugins_dir) @@ -253,13 +282,15 @@ def test_plugin_manager_resolves_repo_local_wordlist_aliases(setup_test_environm medium_wordlist = Path(settings.wordlists_dir) / "medium.txt" medium_wordlist.write_text("admin\nlogin\n", encoding="utf-8") + # dir_discovery now defaults to the bundled "small" list, so request the + # installed "medium" alias explicitly to exercise repo-local resolution. command = manager.build_command( "dir_discovery", - {"base_url": "https://example.com"}, + {"base_url": "https://example.com", "wordlist": "medium"}, ) assert command is not None - assert str(medium_wordlist) in command + assert (str(medium_wordlist) in command) or (medium_wordlist.as_posix() in command) def test_plugin_manager_rejects_linux_wordlist_absolute_default(setup_test_environment): @@ -272,3 +303,67 @@ def test_plugin_manager_rejects_linux_wordlist_absolute_default(setup_test_envir "virtual-host-finder", {"target": "example.com"}, ) + +def test_plugin_validation_presets(setup_test_environment): + """Test validation_type presets on field inputs.""" + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin("http_inspector") + assert plugin is not None + + # Let's mock a field's validation properties + target_field = plugin.fields[0] + orig_validation = target_field.validation + + try: + # Test 1: URL validation_type preset + target_field.validation = {"validation_type": "url", "message": "Must be valid URL"} + # Valid URL + manager._validate_inputs_against_schema(plugin, {target_field.id: "https://example.com/api"}) + # Invalid URL + with pytest.raises(ValueError, match="Must be valid URL"): + manager._validate_inputs_against_schema(plugin, {target_field.id: "invalid-url"}) + + # Test 2: Hostname preset + target_field.validation = {"validation_type": "hostname"} + # Valid hostname + manager._validate_inputs_against_schema(plugin, {target_field.id: "sub.example.com"}) + # Invalid hostname + with pytest.raises(ValueError, match="Must be a valid hostname"): + manager._validate_inputs_against_schema(plugin, {target_field.id: "https://example.com"}) + + # Test 3: Domain preset + target_field.validation = {"validation_type": "domain"} + # Valid domain + manager._validate_inputs_against_schema(plugin, {target_field.id: "example.com"}) + # Invalid domain + with pytest.raises(ValueError, match="Must be a valid domain name"): + manager._validate_inputs_against_schema(plugin, {target_field.id: "https://example.com"}) + + # Test 4: IPv4 preset + target_field.validation = {"validation_type": "ipv4"} + # Valid IP + manager._validate_inputs_against_schema(plugin, {target_field.id: "192.168.1.1"}) + # Invalid IP + with pytest.raises(ValueError, match="Must be a valid IPv4 address"): + manager._validate_inputs_against_schema(plugin, {target_field.id: "999.999.999.999"}) + + # Test 5: Port preset + target_field.validation = {"validation_type": "port"} + # Valid port + manager._validate_inputs_against_schema(plugin, {target_field.id: "8080"}) + # Invalid port + with pytest.raises(ValueError, match="Must be a valid port number"): + manager._validate_inputs_against_schema(plugin, {target_field.id: "70000"}) + + # Test 6: CIDR preset + target_field.validation = {"validation_type": "cidr"} + # Valid CIDR + manager._validate_inputs_against_schema(plugin, {target_field.id: "192.168.1.0/24"}) + # Invalid CIDR + with pytest.raises(ValueError, match="Must be a valid CIDR block"): + manager._validate_inputs_against_schema(plugin, {target_field.id: "192.168.1.1"}) + + finally: + target_field.validation = orig_validation diff --git a/testing/backend/unit/test_process_tree_cancellation.py b/testing/backend/unit/test_process_tree_cancellation.py new file mode 100644 index 000000000..a4470cf45 --- /dev/null +++ b/testing/backend/unit/test_process_tree_cancellation.py @@ -0,0 +1,315 @@ +""" +Tests for full process-tree cancellation (issue #216). + +Covers: + - _terminate_process_group sends SIGTERM then SIGKILL after grace period. + - _terminate_process_group handles already-dead processes without error. + - _terminate_process_group handles ProcessLookupError on getpgid gracefully. + - _execute_command kills the full process group on asyncio.CancelledError. + - _execute_command kills the full process group on timeout. + - start_new_session=True is passed to create_subprocess_exec. + - _process_pids is populated on subprocess start and cleared on finish. + - cancel_task terminates the process group before cancelling the asyncio task. + - Orphan child processes spawned by the root process are killed. + - Double-cancel of a task that already finished is a no-op. +""" + +import asyncio +import os +import signal +import sys +import pytest +import pytest_asyncio +from unittest.mock import AsyncMock, MagicMock, patch, call + +from backend.secuscan.executor import _terminate_process_group, _CANCEL_GRACE_SECONDS + + +class TestTerminateProcessGroup: + @pytest.mark.asyncio + async def test_already_dead_process_no_error(self): + with patch("os.getpgid", side_effect=ProcessLookupError("no such process")): + await _terminate_process_group(99999, "task-dead") + + @pytest.mark.asyncio + async def test_permission_error_on_getpgid_no_error(self): + with patch("os.getpgid", side_effect=PermissionError("permission denied")): + await _terminate_process_group(99999, "task-perm") + + @pytest.mark.asyncio + async def test_sigterm_sent_to_process_group(self): + with ( + patch("os.getpgid", return_value=5000), + patch("os.killpg") as mock_killpg, + ): + mock_killpg.side_effect = [None, ProcessLookupError()] + await _terminate_process_group(1234, "task-sigterm") + mock_killpg.assert_any_call(5000, signal.SIGTERM) + + @pytest.mark.asyncio + async def test_process_exits_before_sigkill(self): + killpg_calls = [] + def fake_killpg(pgid, sig): + killpg_calls.append(sig) + if sig == signal.SIGTERM: + return + raise ProcessLookupError() + + call_count = [0] + def fake_killpg_probe(pgid, sig): + if sig == 0: + call_count[0] += 1 + if call_count[0] >= 2: + raise ProcessLookupError() + else: + killpg_calls.append(sig) + + with ( + patch("os.getpgid", return_value=5001), + patch("os.killpg", side_effect=fake_killpg_probe), + ): + await _terminate_process_group(1235, "task-exits-early") + assert signal.SIGKILL not in killpg_calls + + @pytest.mark.asyncio + async def test_sigterm_permission_error_no_sigkill(self): + with ( + patch("os.getpgid", return_value=5002), + patch("os.killpg", side_effect=PermissionError()) as mock_killpg, + ): + await _terminate_process_group(1236, "task-perm-kill") + mock_killpg.assert_called_once_with(5002, signal.SIGTERM) + + @pytest.mark.asyncio + async def test_sigkill_sent_when_process_does_not_exit(self): + probe_count = [0] + def fake_killpg(pgid, sig): + if sig == 0: + probe_count[0] += 1 + elif sig == signal.SIGKILL: + raise ProcessLookupError() + + with ( + patch("os.getpgid", return_value=5003), + patch("os.killpg", side_effect=fake_killpg) as mock_killpg, + patch("asyncio.sleep", new_callable=AsyncMock), + ): + await _terminate_process_group(1237, "task-stubborn", grace_seconds=1) + sigs = [c.args[1] for c in mock_killpg.call_args_list] + assert signal.SIGTERM in sigs + assert signal.SIGKILL in sigs + + +class TestExecuteCommandProcessGroup: + @pytest.mark.asyncio + async def test_start_new_session_passed_to_subprocess(self): + mock_proc = AsyncMock() + mock_proc.pid = 1000 + mock_proc.returncode = 0 + mock_proc.stdout = AsyncMock() + mock_proc.stdout.at_eof.return_value = True + mock_proc.wait = AsyncMock(return_value=0) + + from backend.secuscan.executor import TaskExecutor + executor = TaskExecutor() + + with patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_create: + await executor._execute_command(["echo", "hello"], "task-sess") + _, kwargs = mock_create.call_args + assert kwargs.get("start_new_session") is True + + @pytest.mark.asyncio + async def test_process_pid_stored_in_registry(self): + mock_proc = AsyncMock() + mock_proc.pid = 2001 + mock_proc.returncode = 0 + mock_proc.stdout = AsyncMock() + mock_proc.stdout.at_eof.return_value = True + mock_proc.wait = AsyncMock(return_value=0) + + from backend.secuscan.executor import TaskExecutor + executor = TaskExecutor() + + captured_pid = {} + original_wait_for = asyncio.wait_for + + async def capturing_wait_for(coro, timeout=None): + captured_pid["pid"] = executor._process_pids.get("task-pid") + try: + await coro + except Exception: + pass + + with patch("asyncio.create_subprocess_exec", return_value=mock_proc): + with patch("asyncio.wait_for", side_effect=capturing_wait_for): + await executor._execute_command(["echo", "hi"], "task-pid") + assert captured_pid.get("pid") == 2001 + + @pytest.mark.asyncio + async def test_terminate_group_called_on_timeout(self): + from backend.secuscan.executor import TaskExecutor + executor = TaskExecutor() + + mock_proc = AsyncMock() + mock_proc.pid = 3001 + mock_proc.returncode = -1 + mock_proc.stdout = AsyncMock() + mock_proc.stdout.at_eof.return_value = False + mock_proc.wait = AsyncMock(return_value=-1) + + with ( + patch("asyncio.create_subprocess_exec", return_value=mock_proc), + patch("asyncio.wait_for", side_effect=asyncio.TimeoutError), + patch("backend.secuscan.executor._terminate_process_group", new_callable=AsyncMock) as mock_term, + ): + output, code = await executor._execute_command(["sleep", "999"], "task-timeout", timeout=1) + mock_term.assert_awaited_once_with(3001, "task-timeout") + assert code == -1 + assert "timed out" in output + + @pytest.mark.asyncio + async def test_terminate_group_called_on_cancel(self): + from backend.secuscan.executor import TaskExecutor + executor = TaskExecutor() + + mock_proc = AsyncMock() + mock_proc.pid = 4001 + mock_proc.returncode = -1 + mock_proc.stdout = AsyncMock() + mock_proc.stdout.at_eof.return_value = False + mock_proc.wait = AsyncMock(return_value=-1) + + async def raise_cancelled(*args, **kwargs): + raise asyncio.CancelledError() + + with ( + patch("asyncio.create_subprocess_exec", return_value=mock_proc), + patch("asyncio.wait_for", side_effect=raise_cancelled), + patch("backend.secuscan.executor._terminate_process_group", new_callable=AsyncMock) as mock_term, + ): + with pytest.raises(asyncio.CancelledError): + await executor._execute_command(["sleep", "999"], "task-cancel") + mock_term.assert_awaited_once_with(4001, "task-cancel") + + @pytest.mark.asyncio + async def test_process_pid_cleared_after_successful_command(self): + from backend.secuscan.executor import TaskExecutor + executor = TaskExecutor() + + mock_proc = AsyncMock() + mock_proc.pid = 5001 + mock_proc.returncode = 0 + mock_proc.stdout = AsyncMock() + mock_proc.stdout.at_eof.return_value = True + mock_proc.wait = AsyncMock(return_value=0) + + with patch("asyncio.create_subprocess_exec", return_value=mock_proc): + await executor._execute_command(["echo", "done"], "task-pid-clear") + + assert "task-pid-clear" not in executor._process_pids + + @pytest.mark.asyncio + async def test_process_pid_cleared_after_timeout(self): + from backend.secuscan.executor import TaskExecutor + executor = TaskExecutor() + + mock_proc = AsyncMock() + mock_proc.pid = 6001 + mock_proc.returncode = -1 + mock_proc.stdout = AsyncMock() + mock_proc.stdout.at_eof.return_value = False + mock_proc.wait = AsyncMock(return_value=-1) + + with ( + patch("asyncio.create_subprocess_exec", return_value=mock_proc), + patch("asyncio.wait_for", side_effect=asyncio.TimeoutError), + patch("backend.secuscan.executor._terminate_process_group", new_callable=AsyncMock), + ): + await executor._execute_command(["sleep", "99"], "task-pid-timeout", timeout=1) + + assert "task-pid-timeout" not in executor._process_pids + + +class TestCancelTaskProcessGroup: + @pytest.mark.asyncio + async def test_cancel_task_terminates_process_group(self): + from backend.secuscan.executor import TaskExecutor + from unittest.mock import MagicMock + executor = TaskExecutor() + + fake_task = MagicMock() + fake_task.cancel = MagicMock(return_value=True) + executor.running_tasks["task-pg"] = fake_task + executor._process_pids["task-pg"] = 7001 + + with ( + patch("backend.secuscan.executor._terminate_process_group", new_callable=AsyncMock) as mock_term, + patch("backend.secuscan.executor.get_db", new_callable=AsyncMock) as mock_get_db, + ): + mock_db = AsyncMock() + mock_get_db.return_value = mock_db + mock_db.execute = AsyncMock() + mock_db.log_audit = AsyncMock() + + await executor.cancel_task("task-pg") + mock_term.assert_awaited_once_with(7001, "task-pg") + fake_task.cancel.assert_called_once() + + @pytest.mark.asyncio + async def test_cancel_task_no_pid_still_cancels_asyncio_task(self): + from backend.secuscan.executor import TaskExecutor + from unittest.mock import MagicMock + executor = TaskExecutor() + + fake_task = MagicMock() + fake_task.cancel = MagicMock(return_value=True) + executor.running_tasks["task-nopid"] = fake_task + + with ( + patch("backend.secuscan.executor._terminate_process_group", new_callable=AsyncMock) as mock_term, + patch("backend.secuscan.executor.get_db", new_callable=AsyncMock) as mock_get_db, + ): + mock_db = AsyncMock() + mock_get_db.return_value = mock_db + mock_db.execute = AsyncMock() + mock_db.log_audit = AsyncMock() + + await executor.cancel_task("task-nopid") + mock_term.assert_not_awaited() + fake_task.cancel.assert_called_once() + + @pytest.mark.asyncio + async def test_cancel_unknown_task_returns_false(self): + from backend.secuscan.executor import TaskExecutor + executor = TaskExecutor() + result = await executor.cancel_task("nonexistent-task-id") + assert result is False + + +class TestOrphanPrevention: + @pytest.mark.asyncio + @pytest.mark.skipif(sys.platform == "win32", reason="process groups not supported on Windows") + async def test_child_process_killed_with_parent(self): + """Spawn a parent that forks a sleeping child; cancel, verify child dies.""" + parent = await asyncio.create_subprocess_exec( + sys.executable, "-c", + "import subprocess, time; subprocess.Popen(['sleep', '30']); time.sleep(30)", + start_new_session=True, + ) + child_pid = parent.pid + pgid = os.getpgid(child_pid) + + await _terminate_process_group(child_pid, "orphan-test", grace_seconds=2) + + try: + await asyncio.wait_for(parent.wait(), timeout=5) + except asyncio.TimeoutError: + pass + + try: + os.killpg(pgid, 0) + still_alive = True + except (ProcessLookupError, PermissionError): + still_alive = False + + assert not still_alive, "Process group should be gone after terminate_process_group" diff --git a/testing/backend/unit/test_redaction.py b/testing/backend/unit/test_redaction.py index 6d6600cf9..1717ad494 100644 --- a/testing/backend/unit/test_redaction.py +++ b/testing/backend/unit/test_redaction.py @@ -354,4 +354,133 @@ def test_access_token_key_is_redacted(self): def test_secret_key_is_redacted(self): inputs = {"secret_key": "aws_secret_abc123456789012345678901234567890"} result = redact_inputs(inputs) - assert result["secret_key"] == REDACTED \ No newline at end of file + assert result["secret_key"] == REDACTED + + +# redact_inputs edge cases (type dispatch, recursion, immutability) + + +class TestRedactInputsEdgeCases: + """Edge cases for redact_inputs(): non-string leaf types, recursive walk, + and the no-mutation guarantee. + """ + + def test_none_value_under_non_sensitive_key_passes_through(self): + inputs = {"target": None} + result = redact_inputs(inputs) + assert result["target"] is None + + def test_boolean_value_under_non_sensitive_key_passes_through(self): + inputs = {"enabled": True, "disabled": False} + result = redact_inputs(inputs) + assert result["enabled"] is True + assert result["disabled"] is False + + def test_integer_value_under_non_sensitive_key_passes_through(self): + inputs = {"count": 42, "max_results": 0, "negative": -7} + result = redact_inputs(inputs) + assert result["count"] == 42 + assert result["max_results"] == 0 + assert result["negative"] == -7 + + def test_float_value_under_non_sensitive_key_passes_through(self): + inputs = {"score": 9.5, "ratio": 0.0} + result = redact_inputs(inputs) + assert result["score"] == 9.5 + assert result["ratio"] == 0.0 + + def test_non_string_under_sensitive_key_is_redacted(self): + # Sensitive keys are matched by name, not by value type. + inputs = {"api_key": 12345, "token": True, "password": 9999} + result = redact_inputs(inputs) + assert result["api_key"] == REDACTED + assert result["token"] == REDACTED + assert result["password"] == REDACTED + + def test_recursive_walk_into_nested_dict(self): + # Nested dicts are walked by _redact_value -> redact_dict, which uses + # pattern-based redaction (not the key-based redaction that fires at + # the top level). A nested secret-shaped string is still caught by + # the pattern redaction, but a nested sensitive key name is not. + inputs = { + "outer": { + "any_key": "api_key=leaked123456789", + "target": "example.com", + } + } + result = redact_inputs(inputs) + assert "leaked123456789" not in result["outer"]["any_key"] + assert REDACTED in result["outer"]["any_key"] + assert result["outer"]["target"] == "example.com" + + def test_recursive_walk_into_list(self): + inputs = { + "items": [ + "api_key=secret1234567abcd", + "Open port 80 detected", + ] + } + result = redact_inputs(inputs) + assert "secret1234567abcd" not in result["items"][0] + assert REDACTED in result["items"][0] + assert result["items"][1] == "Open port 80 detected" + + def test_sensitive_key_inside_nested_list_of_dicts(self): + # Key-based redaction only fires at the top level; nested string values + # are pattern-redacted, so a top-level-only key name in a nested dict + # will not trigger key-based redaction. + inputs = { + "items": [ + {"note": "api_key=leaked123456789"}, + {"note": "benign content"}, + ] + } + result = redact_inputs(inputs) + assert "leaked123456789" not in result["items"][0]["note"] + assert REDACTED in result["items"][0]["note"] + assert result["items"][1]["note"] == "benign content" + + def test_non_dict_input_is_returned_unchanged(self): + # redact_inputs tolerates non-dict inputs by returning them as-is. + assert redact_inputs(None) is None # type: ignore[arg-type] + assert redact_inputs("a string") == "a string" # type: ignore[arg-type] + assert redact_inputs(42) == 42 # type: ignore[arg-type] + assert redact_inputs([1, 2, 3]) == [1, 2, 3] # type: ignore[arg-type] + + def test_does_not_mutate_input_dict(self): + original = { + "api_key": "secret_value_1234567", + "nested": {"password": "inner_secret_1234567"}, + "items": [{"token": "list_token_1234567"}], + } + snapshot_top = dict(original) + snapshot_nested = dict(original["nested"]) + snapshot_items = list(original["items"]) + redact_inputs(original) + assert original == snapshot_top + assert original["nested"] == snapshot_nested + assert original["items"] == snapshot_items + + def test_empty_string_value_under_sensitive_key_is_redacted(self): + inputs = {"api_key": "", "token": ""} + result = redact_inputs(inputs) + assert result["api_key"] == REDACTED + assert result["token"] == REDACTED + + def test_mixed_type_inputs_preserves_types(self): + inputs = { + "target": "example.com", + "port": 443, + "enabled": True, + "tags": ["web", "api"], + "meta": {"version": 1.0, "name": "x"}, + "api_key": "secret_1234567", + } + result = redact_inputs(inputs) + assert result["target"] == "example.com" + assert result["port"] == 443 + assert result["enabled"] is True + assert result["tags"] == ["web", "api"] + assert result["meta"]["version"] == 1.0 + assert result["meta"]["name"] == "x" + assert result["api_key"] == REDACTED diff --git a/testing/backend/unit/test_reporting.py b/testing/backend/unit/test_reporting.py index 1dfa4a856..f1bda505e 100644 --- a/testing/backend/unit/test_reporting.py +++ b/testing/backend/unit/test_reporting.py @@ -78,7 +78,10 @@ def test_generate_pdf_report_handles_long_wrapping_content(): def test_generate_csv_report_includes_new_columns(): csv_output = ReportGenerator.generate_csv_report(sample_task(), sample_result()) - assert "Severity,Title,Category,Target,CVSS,CVE,Description,Evidence,Remediation" in csv_output + assert ( + "Severity,Title,Category,Target,CVSS,CVE,CPE,Validated,Validation Method," + "Confidence Reason,Description,Evidence,Remediation" + ) in csv_output assert "Exposed admin panel" in csv_output assert "CVE-2026-0001" in csv_output diff --git a/testing/backend/unit/test_reporting_charts.py b/testing/backend/unit/test_reporting_charts.py new file mode 100644 index 000000000..05bfbd970 --- /dev/null +++ b/testing/backend/unit/test_reporting_charts.py @@ -0,0 +1,69 @@ +import pytest +from backend.secuscan.reporting import ReportGenerator + +@pytest.fixture +def sample_task(): + return { + "id": "task-123", + "tool_name": "Test Scanner", + "plugin_id": "test_plugin", + "target": "example.com", + "status": "completed", + "created_at": "2026-06-17T12:00:00.000000Z", + "preset": "Full Scan", + "command_used": "test-scanner --target example.com", + "inputs": { + "depth": "deep" + } + } + +@pytest.fixture +def sample_result(): + return { + "findings": [ + { + "id": "finding-1", + "title": "SQL Injection", + "category": "Injection", + "severity": "CRITICAL", + "target": "example.com/login.php", + "description": "A SQL injection vulnerability exists in the login form.", + "proof": "UNION SELECT username, password FROM users;", + "remediation": "Use parameterized SQL queries and input sanitization.", + "validated": True, + }, + { + "id": "finding-2", + "title": "XSS Vulnerability", + "category": "XSS", + "severity": "HIGH", + "target": "example.com/search.php", + "description": "Reflected Cross-Site Scripting via query parameter.", + "proof": "<script>alert(1)</script>", + "remediation": "Escape user input in output HTML rendering.", + "validated": False, + } + ], + "structured": { + "rows": [] + }, + "summary": ["Scan completed successfully."], + "errors": [] + } + +def test_generate_severity_chart(): + severity_counts = { + "CRITICAL": 1, + "HIGH": 2, + "MEDIUM": 0, + "LOW": 4, + "INFO": 0 + } + chart_data = ReportGenerator._generate_severity_chart(severity_counts) + assert isinstance(chart_data, str) + assert chart_data.startswith("data:image/png;base64,") + +def test_html_report_contains_chart(sample_task, sample_result): + html_report = ReportGenerator.generate_html_report(sample_task, sample_result) + assert "Severity Distribution Chart" in html_report + assert "data:image/png;base64," in html_report diff --git a/testing/backend/unit/test_reporting_core.py b/testing/backend/unit/test_reporting_core.py new file mode 100644 index 000000000..597f423f7 --- /dev/null +++ b/testing/backend/unit/test_reporting_core.py @@ -0,0 +1,137 @@ +import json +import csv +import io +import pytest +from backend.secuscan.reporting import ReportGenerator + +@pytest.fixture +def sample_task(): + return { + "id": "task-123", + "tool_name": "Test Scanner", + "plugin_id": "test_plugin", + "target": "example.com", + "status": "completed", + "created_at": "2026-06-17T12:00:00.000000Z", + "preset": "Full Scan", + "command_used": "test-scanner --target example.com", + "inputs": { + "depth": "deep", + "threads": 4, + "enable_ssl": True + } + } + +@pytest.fixture +def sample_result(): + return { + "findings": [ + { + "id": "finding-1", + "title": "SQL Injection", + "category": "Injection", + "severity": "CRITICAL", + "target": "example.com/login.php", + "cvss": 9.8, + "cve": "CVE-2026-0001", + "cwe": "CWE-89", + "cpe": "cpe:/a:test:login:1.0", + "validated": True, + "validation_method": "Exploitation payload sent", + "confidence_reason": "Vulnerability confirmed via active database response.", + "description": "A SQL injection vulnerability exists in the login form.", + "proof": "UNION SELECT username, password FROM users;", + "remediation": "Use parameterized SQL queries and input sanitization.", + "metadata": { + "payload": "' OR 1=1 --" + } + }, + { + "id": "finding-2", + "title": "XSS Vulnerability", + "category": "XSS", + "severity": "HIGH", + "target": "example.com/search.php", + "cvss": 7.5, + "cve": "", + "cwe": "CWE-79", + "validated": False, + "description": "Reflected Cross-Site Scripting via query parameter.", + "proof": "<script>alert(1)</script>", + "remediation": "Escape user input in output HTML rendering." + } + ], + "structured": { + "open_ports": [80, 443], + "technologies": ["Apache", "PHP"], + "rows": [ + {"port": 80, "service": "http"}, + {"port": 443, "service": "https"} + ] + }, + "summary": [ + "The scan completed successfully.", + "Found two high-severity vulnerabilities." + ], + "errors": [] + } + +def test_generate_csv_report(sample_task, sample_result): + csv_report = ReportGenerator.generate_csv_report(sample_task, sample_result) + assert isinstance(csv_report, str) + + # Read CSV + f = io.StringIO(csv_report) + reader = csv.reader(f) + rows = list(reader) + + # Check headers + assert len(rows) > 0 + headers = rows[0] + expected_headers = [ + "Severity", "Title", "Category", "Target", "CVSS", "CVE", "CPE", + "Validated", "Validation Method", "Confidence Reason", "Description", + "Evidence", "Remediation" + ] + assert headers == expected_headers + + # Check rows + assert len(rows) == 3 # Header + 2 findings + assert rows[1][0] == "CRITICAL" + assert rows[1][1] == "SQL Injection" + assert rows[1][7] == "yes" + assert rows[2][0] == "HIGH" + assert rows[2][7] == "no" + +def test_generate_html_report(sample_task, sample_result): + html_report = ReportGenerator.generate_html_report(sample_task, sample_result) + assert isinstance(html_report, str) + assert "<!DOCTYPE html>" in html_report + assert "SecuScan Report" in html_report + assert "SQL Injection" in html_report + assert "XSS Vulnerability" in html_report + assert "example.com" in html_report + +def test_generate_pdf_report(sample_task, sample_result): + pdf_report = ReportGenerator.generate_pdf_report(sample_task, sample_result) + assert isinstance(pdf_report, bytes) + # PDF header signature + assert pdf_report.startswith(b"%PDF") + +def test_generate_sarif_report(sample_task, sample_result): + sarif_report = ReportGenerator.generate_sarif_report(sample_task, sample_result) + assert isinstance(sarif_report, str) + + # Parse JSON + sarif_data = json.loads(sarif_report) + assert sarif_data["version"] == "2.1.0" + assert "runs" in sarif_data + run = sarif_data["runs"][0] + assert run["tool"]["driver"]["name"] == "Test Scanner" + + # Check results + results = run["results"] + assert len(results) == 2 + assert results[0]["ruleId"] == "cve-2026-0001" # Derived from CVE + assert results[0]["level"] == "error" + assert results[1]["ruleId"] == "cwe-79" # Derived from CWE diff --git a/testing/backend/unit/test_request_context.py b/testing/backend/unit/test_request_context.py new file mode 100644 index 000000000..247314fde --- /dev/null +++ b/testing/backend/unit/test_request_context.py @@ -0,0 +1,33 @@ +from backend.secuscan.request_context import ( + get_request_id, + request_id_context, + set_request_id, +) + + +def test_set_request_id_override(): + request_id = set_request_id("custom-id") + + assert request_id == "custom-id" + assert get_request_id() == "custom-id" + + +def test_set_request_id_generates_id(monkeypatch): + monkeypatch.setattr( + "backend.secuscan.request_context.uuid4", + lambda: "fixed-uuid", + ) + + request_id = set_request_id() + + assert request_id == "fixed-uuid" + assert get_request_id() == "fixed-uuid" + + +def test_get_request_id_default(): + token = request_id_context.set("") + + try: + assert get_request_id() == "" + finally: + request_id_context.reset(token) \ No newline at end of file diff --git a/testing/backend/unit/test_request_middleware.py b/testing/backend/unit/test_request_middleware.py new file mode 100644 index 000000000..a60d366ba --- /dev/null +++ b/testing/backend/unit/test_request_middleware.py @@ -0,0 +1,30 @@ +from fastapi.testclient import TestClient + +from backend.secuscan.main import app + + +class TestRequestIDMiddleware: + def test_preserves_existing_request_id(self): + with TestClient(app) as client: + response = client.get( + "/api/v1/health", + headers={"X-Request-ID": "test-request-123"}, + ) + + assert response.status_code == 200 + assert response.headers["X-Request-ID"] == "test-request-123" + + def test_generates_request_id_when_missing(self): + with TestClient(app) as client: + response = client.get("/api/v1/health") + + assert response.status_code == 200 + assert "X-Request-ID" in response.headers + assert response.headers["X-Request-ID"] + + def test_response_always_contains_request_id_header(self): + with TestClient(app) as client: + response = client.get("/api/v1/health") + + assert response.status_code == 200 + assert "X-Request-ID" in response.headers \ No newline at end of file diff --git a/testing/backend/unit/test_routes_deserialization.py b/testing/backend/unit/test_routes_deserialization.py new file mode 100644 index 000000000..e55ab6388 --- /dev/null +++ b/testing/backend/unit/test_routes_deserialization.py @@ -0,0 +1,184 @@ +""" +Unit tests for routes JSON deserialization helpers. + +Imports the real production functions from backend.secuscan.routes_json_helpers +so a regression in the actual implementation is caught by these tests. +""" + +import json + +from backend.secuscan.routes_json_helpers import ( + FINDING_JSON_FIELDS, + deserialize_asset_service_rows, + deserialize_finding_rows, + parse_json_fields, +) + + +# parse_json_fields + + +def test_parse_json_fields_parses_valid_json_string(): + rows = [{"id": 1, "metadata_json": '{"key": "value"}'}] + result = parse_json_fields(rows, ["metadata_json"]) + assert result[0]["metadata_json"] == {"key": "value"} + + +def test_parse_json_fields_preserves_non_json_fields(): + rows = [{"id": 1, "name": "test", "data_json": '{"x": 1}'}] + result = parse_json_fields(rows, ["data_json"]) + assert result[0]["id"] == 1 + assert result[0]["name"] == "test" + assert result[0]["data_json"] == {"x": 1} + + +def test_parse_json_fields_invalid_json_string(): + rows = [{"id": 1, "data_json": "not valid json{"}] + result = parse_json_fields(rows, ["data_json"]) + assert result[0]["data_json"] == "not valid json{" + + +def test_parse_json_fields_already_parsed_dict(): + rows = [{"id": 1, "data_json": {"already": "parsed"}}] + result = parse_json_fields(rows, ["data_json"]) + assert result[0]["data_json"] == {"already": "parsed"} + + +def test_parse_json_fields_empty_rows(): + assert parse_json_fields([], ["metadata_json"]) == [] + + +def test_parse_json_fields_missing_field(): + rows = [{"id": 1, "name": "orphan"}] + result = parse_json_fields(rows, ["metadata_json"]) + assert result[0]["id"] == 1 + assert "metadata_json" not in result[0] + + +def test_parse_json_fields_multiple_fields(): + rows = [{"id": 1, "meta_json": '{"a":1}', "ref_json": '{"b":2}'}] + result = parse_json_fields(rows, ["meta_json", "ref_json"]) + assert result[0]["meta_json"] == {"a": 1} + assert result[0]["ref_json"] == {"b": 2} + + +def test_parse_json_fields_null_field_value(): + rows = [{"id": 1, "data_json": None}, {"id": 2}] + result = parse_json_fields(rows, ["data_json"]) + assert result[0]["data_json"] is None + assert "data_json" not in result[1] + + +# deserialize_finding_rows + + +def test_deserialize_finding_rows_renames_all_json_fields(): + row = { + "id": "f1", + "metadata_json": '{"severity": "high"}', + "risk_factors_json": '["owasp"]', + "evidence_json": '{"screenshot": "http://example.com"}', + "asset_refs_json": '["asset-1"]', + "references_json": '["https://ref.com"]', + "corroborating_sources_json": '["src-a"]', + } + result = deserialize_finding_rows([row]) + finding = result[0] + + assert "metadata_json" not in finding + assert "risk_factors_json" not in finding + assert "evidence_json" not in finding + assert "asset_refs_json" not in finding + assert "references_json" not in finding + assert "corroborating_sources_json" not in finding + + assert finding["metadata"] == {"severity": "high"} + assert finding["risk_factors"] == ["owasp"] + assert finding["evidence"] == {"screenshot": "http://example.com"} + assert finding["asset_refs"] == ["asset-1"] + assert finding["references"] == ["https://ref.com"] + assert finding["corroborating_sources"] == ["src-a"] + + +def test_deserialize_finding_rows_id_preserved(): + row = {"id": "finding-123", "metadata_json": "{}"} + result = deserialize_finding_rows([row]) + assert result[0]["id"] == "finding-123" + + +def test_deserialize_finding_rows_partial_fields(): + row = {"id": "f1", "metadata_json": '{"key": "val"}'} + result = deserialize_finding_rows([row]) + assert result[0]["metadata"] == {"key": "val"} + assert "risk_factors" not in result[0] + + +def test_deserialize_finding_rows_empty_input(): + assert deserialize_finding_rows([]) == [] + + +def test_deserialize_finding_rows_invalid_json_leaves_field(): + # parse_json_fields preserves the raw string on JSONDecodeError, so the + # renamed metadata key holds the original string. + row = {"id": "f1", "metadata_json": "not-json"} + result = deserialize_finding_rows([row]) + assert "metadata_json" not in result[0] + assert result[0]["metadata"] == "not-json" + + +# deserialize_asset_service_rows + + +def test_deserialize_asset_service_rows_renames_both_fields(): + row = { + "id": "a1", + "metadata_json": '{"ip": "1.2.3.4"}', + "cert_san_json": '["host1.local"]', + } + result = deserialize_asset_service_rows([row]) + asset = result[0] + + assert "metadata_json" not in asset + assert "cert_san_json" not in asset + assert asset["metadata"] == {"ip": "1.2.3.4"} + assert asset["cert_san"] == ["host1.local"] + + +def test_deserialize_asset_service_rows_id_preserved(): + row = {"id": "asset-99", "ip_address": "5.6.7.8", "metadata_json": "{}"} + result = deserialize_asset_service_rows([row]) + assert result[0]["id"] == "asset-99" + assert result[0]["ip_address"] == "5.6.7.8" + + +def test_deserialize_asset_service_rows_partial_fields(): + row = {"id": "a1", "metadata_json": '{"port": 443}'} + result = deserialize_asset_service_rows([row]) + assert result[0]["metadata"] == {"port": 443} + assert "cert_san" not in result[0] + + +def test_deserialize_asset_service_rows_empty_input(): + assert deserialize_asset_service_rows([]) == [] + + +def test_deserialize_asset_service_rows_only_metadata(): + row = {"id": "svc-1", "metadata_json": '{"service": "http"}'} + result = deserialize_asset_service_rows([row]) + assert result[0]["metadata"] == {"service": "http"} + assert "cert_san" not in result[0] + + +# FINDING_JSON_FIELDS constant + + +def test_finding_json_fields_has_expected_keys(): + expected = { + "metadata_json", + "risk_factors_json", + "evidence_json", + "asset_refs_json", + "references_json", + "corroborating_sources_json", + } + assert set(FINDING_JSON_FIELDS) == expected diff --git a/testing/backend/unit/test_routes_json_helpers.py b/testing/backend/unit/test_routes_json_helpers.py new file mode 100644 index 000000000..a11247172 --- /dev/null +++ b/testing/backend/unit/test_routes_json_helpers.py @@ -0,0 +1,156 @@ +""" +Unit tests for routes.py JSON deserialization helpers. +""" +import pytest + +from backend.secuscan.routes import ( + parse_json_fields, + deserialize_finding_rows, + deserialize_asset_service_rows, + _parse_workflow_steps, +) + + +class TestParseJsonFields: + def test_decodes_valid_json_string(self): + rows = [{"field": '{"key": "val"}'}] + result = parse_json_fields(rows, ["field"]) + assert result[0]["field"] == {"key": "val"} + + def test_skips_non_string_values(self): + rows = [{"field": 42}] + result = parse_json_fields(rows, ["field"]) + assert result[0]["field"] == 42 + + def test_skips_missing_fields(self): + rows = [{}] + result = parse_json_fields(rows, ["field"]) + assert result[0] == {} + + def test_handles_bad_json_unchanged(self): + rows = [{"field": "not-json"}] + result = parse_json_fields(rows, ["field"]) + assert result[0]["field"] == "not-json" + + def test_handles_none_value(self): + rows = [{"field": None}] + result = parse_json_fields(rows, ["field"]) + assert result[0]["field"] is None + + def test_multiple_rows_and_fields(self): + rows = [ + {"a": '{"x":1}', "b": '{"y":2}'}, + {"a": '{"z":3}', "b": None}, + ] + result = parse_json_fields(rows, ["a", "b"]) + assert result[0]["a"] == {"x": 1} + assert result[0]["b"] == {"y": 2} + assert result[1]["a"] == {"z": 3} + assert result[1]["b"] is None + + def test_empty_rows_list(self): + result = parse_json_fields([], ["field"]) + assert result == [] + + +class TestDeserializeFindingRows: + def test_renames_json_keys_to_clean_api_names(self): + rows = [ + { + "metadata_json": '{"src": "test"}', + "risk_factors_json": '[]', + "evidence_json": '[]', + "asset_refs_json": '[]', + "references_json": '[]', + "corroborating_sources_json": '[]', + "title": "Test Finding", + } + ] + result = deserialize_finding_rows(rows) + f = result[0] + assert "metadata" in f + assert "metadata_json" not in f + assert f["metadata"] == {"src": "test"} + assert "risk_factors" in f + assert "evidence" in f + assert "asset_refs" in f + assert "references" in f + assert "corroborating_sources" in f + assert f["title"] == "Test Finding" + + def test_preserves_non_json_fields(self): + rows = [{"title": "Hello", "severity": "high", "metadata_json": "{}"}] + result = deserialize_finding_rows(rows) + assert result[0]["title"] == "Hello" + assert result[0]["severity"] == "high" + + def test_bad_json_in_metadata_field_unchanged(self): + rows = [{"metadata_json": "broken{", "risk_factors_json": "also-broken{"}] + result = deserialize_finding_rows(rows) + assert result[0]["metadata"] == "broken{" + assert result[0]["risk_factors"] == "also-broken{" + + +class TestDeserializeAssetServiceRows: + def test_renames_metadata_json_and_cert_san_json(self): + rows = [ + { + "metadata_json": '{"host": "web"}', + "cert_san_json": '["corp.local"]', + "port": 443, + } + ] + result = deserialize_asset_service_rows(rows) + r = result[0] + assert "metadata" in r + assert "cert_san" in r + assert r["metadata"] == {"host": "web"} + assert r["cert_san"] == ["corp.local"] + assert r["port"] == 443 + + def test_bad_json_unchanged(self): + rows = [{"metadata_json": "not-json"}] + result = deserialize_asset_service_rows(rows) + assert result[0]["metadata"] == "not-json" + + +class TestParseWorkflowSteps: + def test_from_list_with_valid_step(self): + steps = [{"plugin_id": "nmap", "inputs": {"target": "127.0.0.1"}}] + result = _parse_workflow_steps(steps) + assert len(result) == 1 + assert result[0]["plugin_id"] == "nmap" + assert result[0]["inputs"] == {"target": "127.0.0.1"} + + def test_from_json_string(self): + result = _parse_workflow_steps('[{"plugin_id": "nmap", "inputs": {}}]') + assert len(result) == 1 + assert result[0]["plugin_id"] == "nmap" + + def test_from_none_returns_empty_list(self): + result = _parse_workflow_steps(None) + assert result == [] + + def test_from_empty_string_returns_empty_list(self): + result = _parse_workflow_steps("") + assert result == [] + + def test_skips_non_dict_items(self): + result = _parse_workflow_steps(["not-a-dict", 123, {"plugin_id": "x", "inputs": {}}]) + assert len(result) == 1 + assert result[0]["plugin_id"] == "x" + + def test_dicts_without_plugin_id_produce_empty_plugin_id(self): + # plugin_id="" is a valid WorkflowStep; the function coerces missing key to "" + result = _parse_workflow_steps([{"inputs": {}}]) + assert len(result) == 1 + assert result[0]["plugin_id"] == "" + + def test_preserves_preset_field(self): + steps = [{"plugin_id": "nmap", "inputs": {}, "preset": "fast", "execution_context": {}}] + result = _parse_workflow_steps(steps) + assert result[0]["preset"] == "fast" + + def test_empty_list_returns_empty(self): + result = _parse_workflow_steps([]) + assert result == [] diff --git a/testing/backend/unit/test_sarif.py b/testing/backend/unit/test_sarif.py index 374d416ac..91980e07a 100644 --- a/testing/backend/unit/test_sarif.py +++ b/testing/backend/unit/test_sarif.py @@ -27,6 +27,10 @@ def test_generate_sarif_report_with_typical_findings(): "remediation": "Restrict access.", "cve": "CVE-2026-0001", "cvss": 8.1, + "cpe": "cpe:/a:nginx:nginx:1.18.0", + "validated": True, + "validation_method": "service_fingerprint", + "confidence_reason": "Matched live service banner and correlated local CPE intelligence.", }, { "title": "Cross-Site Scripting", @@ -72,6 +76,8 @@ def test_generate_sarif_report_with_typical_findings(): assert rules[0]["shortDescription"]["text"] == "Exposed admin panel" assert rules[0]["fullDescription"]["text"] == "Admin panel is reachable without restrictions." assert rules[0]["help"]["text"] == "Restrict access." + assert rules[0]["properties"]["cpe"] == "cpe:/a:nginx:nginx:1.18.0" + assert rules[0]["properties"]["validated"] is True # Rule 1 (CWE) assert rules[1]["id"] == "cwe-79" @@ -88,6 +94,8 @@ def test_generate_sarif_report_with_typical_findings(): # Result 0 (File with line) assert results[0]["ruleId"] == "cve-2026-0001" assert results[0]["level"] == "error" # HIGH -> error + assert results[0]["properties"]["cpe"] == "cpe:/a:nginx:nginx:1.18.0" + assert results[0]["properties"]["validated"] is True loc0 = results[0]["locations"][0]["physicalLocation"] assert loc0["artifactLocation"]["uri"] == "src/admin.py" assert loc0["region"]["startLine"] == 45 diff --git a/testing/backend/unit/test_semgrep_scanner_plugin.py b/testing/backend/unit/test_semgrep_scanner_plugin.py new file mode 100644 index 000000000..032f2c938 --- /dev/null +++ b/testing/backend/unit/test_semgrep_scanner_plugin.py @@ -0,0 +1,116 @@ +"""Parser and contract coverage for plugins/semgrep_scanner.""" + +from __future__ import annotations + +import asyncio +import importlib.util +import json +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.executor import executor +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "semgrep_scanner" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" + + +def _load_semgrep_parser(): + spec = importlib.util.spec_from_file_location("semgrep_parser", PARSER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def plugin_manager(setup_test_environment) -> PluginManager: + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + return manager + + +def test_semgrep_parser_valid_json(): + parser = _load_semgrep_parser() + valid_json = json.dumps( + { + "results": [ + { + "check_id": "rule-1", + "path": "src/main.py", + "start": {"line": 42}, + "extra": { + "message": "Found an issue", + "severity": "ERROR", + "lines": "eval(user_input)", + }, + } + ] + } + ) + + parsed = parser.parse(valid_json) + assert parsed["count"] == 1 + assert len(parsed["findings"]) == 1 + + finding = parsed["findings"][0] + assert finding["title"] == "Semgrep issue: rule-1 in src/main.py" + assert finding["severity"] == "high" + assert finding["description"] == "Found an issue" + assert finding["metadata"]["rule_id"] == "rule-1" + assert finding["metadata"]["file"] == "src/main.py" + assert finding["metadata"]["line"] == 42 + assert finding["metadata"]["evidence"] == "eval(user_input)" + assert finding["metadata"]["semgrep_severity"] == "ERROR" + + +def test_semgrep_parser_invalid_json(): + parser = _load_semgrep_parser() + invalid_json = "This is not JSON data" + + parsed = parser.parse(invalid_json) + assert parsed["count"] == 0 + assert parsed["findings"] == [] + + +def test_semgrep_parser_missing_fields(): + parser = _load_semgrep_parser() + missing_fields_json = json.dumps( + { + "results": [ + { + # Missing check_id, path, start, extra + } + ] + } + ) + + parsed = parser.parse(missing_fields_json) + assert parsed["count"] == 1 + finding = parsed["findings"][0] + assert finding["title"] == "Semgrep issue: Unknown Rule in Unknown Path" + assert finding["description"] == "No message provided" + assert finding["severity"] == "info" + assert finding["metadata"]["rule_id"] == "Unknown Rule" + assert finding["metadata"]["file"] == "Unknown Path" + assert finding["metadata"]["line"] == 0 + assert finding["metadata"]["evidence"] == "" + assert finding["metadata"]["semgrep_severity"] == "INFO" + + +def test_semgrep_parser_severity_mapping(): + parser = _load_semgrep_parser() + test_cases = [ + ("INFO", "info"), + ("WARNING", "medium"), + ("ERROR", "high"), + ("UNKNOWN_SEVERITY", "low"), + ] + + for semgrep_sev, expected_secuscan_sev in test_cases: + json_data = json.dumps({"results": [{"extra": {"severity": semgrep_sev}}]}) + + parsed = parser.parse(json_data) + assert parsed["findings"][0]["severity"] == expected_secuscan_sev diff --git a/testing/backend/unit/test_smtp_notifications.py b/testing/backend/unit/test_smtp_notifications.py new file mode 100644 index 000000000..c299eb10e --- /dev/null +++ b/testing/backend/unit/test_smtp_notifications.py @@ -0,0 +1,141 @@ +from unittest.mock import MagicMock, patch +import pytest +from backend.secuscan.config import settings +from backend.secuscan.notification_service import send_email + +@pytest.fixture +def smtp_payload(): + return { + "finding": { + "id": "finding-123", + "task_id": "task-456", + "plugin_id": "plugin-xyz", + "title": "Exposed credentials", + "category": "Credentials", + "severity": "critical", + "target": "https://example.com", + "description": "API key was found exposed in JavaScript code.", + "remediation": "Revoke the API key and configure it securely on the server side." + } + } + +@pytest.mark.anyio +@patch("smtplib.SMTP") +async def test_send_email_success(mock_smtp_class, smtp_payload): + # Set settings variables + settings.smtp_username = "testuser" + settings.smtp_password = "testpassword" + settings.smtp_host = "smtp.test.com" + settings.smtp_port = 587 + settings.smtp_from_email = "test@secuscan.io" + settings.smtp_use_tls = True + + # Setup mock SMTP server instance + mock_server = MagicMock() + mock_smtp_class.return_value = mock_server + mock_server.__enter__.return_value = mock_server + + ok, error = await send_email("recipient@target.com", smtp_payload) + + assert ok is True + assert error is None + + # Verify calls + mock_smtp_class.assert_called_once_with("smtp.test.com", 587, timeout=10.0) + mock_server.starttls.assert_called_once() + mock_server.login.assert_called_once_with("testuser", "testpassword") + mock_server.sendmail.assert_called_once() + + +@pytest.mark.anyio +@patch("smtplib.SMTP") +async def test_send_email_disabled_when_no_credentials(mock_smtp_class, smtp_payload): + # Reset settings variables to None + settings.smtp_username = None + settings.smtp_password = None + + ok, error = await send_email("recipient@target.com", smtp_payload) + + # Should skip sending and return True with no error (fallback mode) + assert ok is True + assert error is None + mock_smtp_class.assert_not_called() + + +@pytest.mark.anyio +@patch("smtplib.SMTP") +async def test_send_email_smtp_failure(mock_smtp_class, smtp_payload): + settings.smtp_username = "testuser" + settings.smtp_password = "testpassword" + + # Setup mock SMTP server that raises an exception on login + mock_server = MagicMock() + mock_smtp_class.return_value = mock_server + mock_server.__enter__.return_value = mock_server + mock_server.login.side_effect = Exception("SMTP Auth Failed") + + ok, error = await send_email("recipient@target.com", smtp_payload) + + assert ok is False + assert error == "SMTP Auth Failed" + + +@pytest.mark.anyio +@patch("smtplib.SMTP") +async def test_send_email_html_escaping(mock_smtp_class): + settings.smtp_username = "testuser" + settings.smtp_password = "testpassword" + settings.smtp_host = "smtp.test.com" + settings.smtp_port = 587 + settings.smtp_from_email = "test@secuscan.io" + settings.smtp_use_tls = True + + mock_server = MagicMock() + mock_smtp_class.return_value = mock_server + mock_server.__enter__.return_value = mock_server + + xss_payload = { + "finding": { + "id": "finding-123", + "task_id": "task-456", + "plugin_id": "plugin-xyz", + "title": "<script>alert('title')</script>", + "category": "Credentials", + "severity": "critical", + "target": "<img src=x onerror=alert('target')>", + "description": "<div class=\"xss\">description</div>\nnew line", + "remediation": "<iframe src=\"javascript:alert('remediation')\"></iframe>" + } + } + + ok, error = await send_email("recipient@target.com", xss_payload) + assert ok is True + assert error is None + + # Get email content sent to sendmail + assert mock_server.sendmail.called + call_args = mock_server.sendmail.call_args[0] + msg_str = call_args[2] + + import email + msg = email.message_from_string(msg_str) + html_part = None + for part in msg.walk(): + if part.get_content_type() == "text/html": + html_part = part.get_payload(decode=True).decode("utf-8") + + assert html_part is not None + # Verify HTML escaping + assert "<script>alert('title')</script>" in html_part + assert "<img src=x onerror=alert('target')>" in html_part + assert "<div class="xss">description</div>" in html_part + assert "<iframe src="javascript:alert('remediation')"></iframe>" in html_part + + # Check that newlines in description/remediation are replaced with <br> + assert "description</div><br>new line" in html_part + + # Ensure unescaped tags are NOT in the HTML part + assert "<script>" not in html_part + assert "<img src" not in html_part + assert "<div class=" not in html_part + assert "<iframe>" not in html_part diff --git a/testing/backend/unit/test_sniper_plugin.py b/testing/backend/unit/test_sniper_plugin.py new file mode 100644 index 000000000..f09a4093c --- /dev/null +++ b/testing/backend/unit/test_sniper_plugin.py @@ -0,0 +1,106 @@ +"""Parser and contract coverage for plugins/sniper (issue #508).""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.executor import executor +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "sniper" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / PLUGIN_ID / "sample_output.txt" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" + + +def _load_sniper_parser(): + spec = importlib.util.spec_from_file_location("sniper_parser", PARSER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def plugin_manager(setup_test_environment) -> PluginManager: + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + return manager + + +def test_sniper_metadata_loads_through_validation_path(plugin_manager): + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "Sniper: Auto-Exploiter" + assert plugin.category == "exploit" + assert plugin.safety.get("level") == "exploit" + assert plugin.safety.get("requires_consent") is True + + schema = plugin_manager.get_plugin_schema(PLUGIN_ID) + assert schema is not None + field_ids = {field["id"] for field in schema["fields"]} + assert "target" in field_ids + + +def test_sniper_build_command_renders_representative_target(plugin_manager): + target = "secuscan.in" + command = plugin_manager.build_command(PLUGIN_ID, {"target": target}) + + assert command is not None + assert command[0] == "python3" + assert command[1] == "-c" + assert target in command + assert "Sniper simulation started" in command[2] + + +def test_sniper_parser_fixture_produces_stable_findings(plugin_manager): + parser = _load_sniper_parser() + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + + parsed = parser.parse(raw_output) + assert parsed["count"] == 3 + assert len(parsed["findings"]) == 3 + assert parsed["items"] == [ + "Sniper simulation started", + "target=secuscan.in", + "status=planned_exploit_path", + ] + + first = parsed["findings"][0] + assert first["title"] == "Recon/Scan Observation" + assert first["category"] == "Security Scan" + assert first["severity"] == "info" + assert first["metadata"]["raw"] == "Sniper simulation started" + + exploit_line = parsed["findings"][-1] + assert exploit_line["severity"] == "high" + assert "exploit" in exploit_line["description"].lower() + + +def test_sniper_parser_empty_output_is_deterministic(plugin_manager): + parser = _load_sniper_parser() + parsed = parser.parse("") + + assert parsed["findings"] == [] + assert parsed["count"] == 0 + assert parsed["items"] == [] + + +def test_sniper_executor_normalizes_parser_fixture(plugin_manager): + parser = _load_sniper_parser() + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + + parsed = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + normalized = executor._normalize_parsed_result(plugin, FIXTURE_PATH.read_text(encoding="utf-8"), parsed) + + assert normalized["count"] == 3 + assert len(normalized["findings"]) == 3 + assert normalized["findings"][-1]["severity"] == "high" + assert all(f["title"] for f in normalized["findings"]) + assert all(f["category"] for f in normalized["findings"]) diff --git a/testing/backend/unit/test_spider_plugin.py b/testing/backend/unit/test_spider_plugin.py new file mode 100644 index 000000000..057898820 --- /dev/null +++ b/testing/backend/unit/test_spider_plugin.py @@ -0,0 +1,95 @@ +"""Parser and contract coverage for plugins/spider (issue #509).""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.executor import executor +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "spider" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / PLUGIN_ID / "sample_output.txt" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" + + +def _load_spider_parser(): + spec = importlib.util.spec_from_file_location("spider_parser", PARSER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def plugin_manager(setup_test_environment) -> PluginManager: + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + return manager + + +def test_spider_metadata_loads_through_validation_path(plugin_manager): + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "Spider" + assert plugin.category == "robots" + assert plugin.safety.get("level") == "intrusive" + assert plugin.safety.get("requires_consent") is True + + schema = plugin_manager.get_plugin_schema(PLUGIN_ID) + assert schema is not None + field_ids = {field["id"] for field in schema["fields"]} + assert {"target", "depth"} <= field_ids + + +def test_spider_build_command_renders_representative_target(plugin_manager): + target = "https://secuscan.in" + command = plugin_manager.build_command(PLUGIN_ID, {"target": target}) + + assert command is not None + assert command[:4] == ["katana", "-u", target, "-jc"] + assert "-depth" in command + assert "3" in command + assert command[-1] == "-silent" + + +def test_spider_parser_fixture_produces_stable_findings(plugin_manager): + parser = _load_spider_parser() + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + + parsed = parser.parse(raw_output) + assert parsed["count"] == 3 + assert len(parsed["findings"]) == 3 + assert parsed["items"][-1] == "found 2 endpoints during crawl" + + summary = parsed["findings"][-1] + assert summary["title"] == "Recon/Scan Observation" + assert summary["severity"] == "low" + assert summary["metadata"]["raw"] == "found 2 endpoints during crawl" + + +def test_spider_parser_empty_output_is_deterministic(plugin_manager): + parser = _load_spider_parser() + parsed = parser.parse("") + + assert parsed["findings"] == [] + assert parsed["count"] == 0 + assert parsed["items"] == [] + + +def test_spider_executor_normalizes_parser_fixture(plugin_manager): + parser = _load_spider_parser() + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + + parsed = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + normalized = executor._normalize_parsed_result(plugin, FIXTURE_PATH.read_text(encoding="utf-8"), parsed) + + assert normalized["count"] == 3 + assert normalized["findings"][-1]["severity"] == "low" + assert all(f["title"] for f in normalized["findings"]) diff --git a/testing/backend/unit/test_sqli_exploiter_plugin.py b/testing/backend/unit/test_sqli_exploiter_plugin.py new file mode 100644 index 000000000..8023a5519 --- /dev/null +++ b/testing/backend/unit/test_sqli_exploiter_plugin.py @@ -0,0 +1,98 @@ +"""Parser and contract coverage for plugins/sqli_exploiter (issue #510).""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.executor import executor +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "sqli_exploiter" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / PLUGIN_ID / "sample_output.txt" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" + + +def _load_sqli_exploiter_parser(): + spec = importlib.util.spec_from_file_location("sqli_exploiter_parser", PARSER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def plugin_manager(setup_test_environment) -> PluginManager: + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + return manager + + +def test_sqli_exploiter_metadata_loads_through_validation_path(plugin_manager): + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "SQLi Exploiter" + assert plugin.category == "exploit" + assert plugin.safety.get("level") == "exploit" + assert plugin.safety.get("requires_consent") is True + + schema = plugin_manager.get_plugin_schema(PLUGIN_ID) + assert schema is not None + field_ids = {field["id"] for field in schema["fields"]} + assert {"target", "level", "risk"} <= field_ids + + +def test_sqli_exploiter_build_command_renders_representative_target(plugin_manager): + target = "https://vuln.local/item?id=1" + command = plugin_manager.build_command(PLUGIN_ID, {"target": target}) + + assert command is not None + assert command[:4] == ["sqlmap", "-u", target, "--batch"] + assert "--dbs" in command + assert "--level" in command + assert "2" in command + assert "--risk" in command + + +def test_sqli_exploiter_parser_fixture_produces_stable_findings(plugin_manager): + parser = _load_sqli_exploiter_parser() + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + + parsed = parser.parse(raw_output) + assert parsed["count"] == 4 + assert len(parsed["findings"]) == 4 + + injection_line = parsed["findings"][2] + assert injection_line["severity"] == "high" + assert "injection" in injection_line["description"].lower() + + exploit_line = parsed["findings"][-1] + assert exploit_line["severity"] == "high" + assert "exploit" in exploit_line["description"].lower() + + +def test_sqli_exploiter_parser_empty_output_is_deterministic(plugin_manager): + parser = _load_sqli_exploiter_parser() + parsed = parser.parse("") + + assert parsed["findings"] == [] + assert parsed["count"] == 0 + assert parsed["items"] == [] + + +def test_sqli_exploiter_executor_normalizes_parser_fixture(plugin_manager): + parser = _load_sqli_exploiter_parser() + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + + parsed = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + normalized = executor._normalize_parsed_result(plugin, FIXTURE_PATH.read_text(encoding="utf-8"), parsed) + + assert normalized["count"] == 4 + assert normalized["findings"][-1]["severity"] == "high" + assert all(f["title"] for f in normalized["findings"]) diff --git a/testing/backend/unit/test_subdomain_finder_plugin.py b/testing/backend/unit/test_subdomain_finder_plugin.py new file mode 100644 index 000000000..20fcd69f1 --- /dev/null +++ b/testing/backend/unit/test_subdomain_finder_plugin.py @@ -0,0 +1,118 @@ +"""Parser and contract coverage for plugins/subdomain_discovery (issue #511). + +The issue references the legacy plugin id ``subdomain-finder``; upstream renamed +that plugin to ``subdomain_discovery``. +""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.executor import executor +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "subdomain_discovery" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / PLUGIN_ID / "sample_output.txt" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" + + +def _load_subdomain_discovery_parser(): + spec = importlib.util.spec_from_file_location("subdomain_discovery_parser", PARSER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def plugin_manager(setup_test_environment) -> PluginManager: + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + return manager + + +def test_subdomain_discovery_metadata_loads_through_validation_path(plugin_manager): + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "Subdomain Scanner" + assert plugin.category == "recon" + assert plugin.safety.get("level") == "safe" + assert plugin.safety.get("requires_consent") is False + + schema = plugin_manager.get_plugin_schema(PLUGIN_ID) + assert schema is not None + field_ids = {field["id"] for field in schema["fields"]} + assert {"target", "all", "threads"} <= field_ids + + +def test_subdomain_discovery_build_command_renders_representative_target(plugin_manager): + target = "secuscan.in" + command = plugin_manager.build_command(PLUGIN_ID, {"target": target}) + + assert command is not None + assert command[:4] == ["subfinder", "-d", target, "-silent"] + assert "-t" in command + assert "10" in command + assert "-all" not in command + + +def test_subdomain_discovery_build_command_includes_all_flag_when_enabled(plugin_manager): + target = "secuscan.in" + command = plugin_manager.build_command( + PLUGIN_ID, + {"target": target, "all": True, "threads": 20}, + ) + + assert command is not None + assert "-all" in command + assert "-t" in command + assert "20" in command + + +def test_subdomain_discovery_parser_fixture_produces_stable_findings(plugin_manager): + parser = _load_subdomain_discovery_parser() + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + + parsed = parser.parse(raw_output) + assert parsed["count"] == 3 + assert len(parsed["findings"]) == 3 + assert parsed["subdomains"] == [ + "api.secuscan.in", + "staging.secuscan.in", + "dev.secuscan.in", + ] + + first = parsed["findings"][0] + assert first["title"] == "Subdomain Discovered: api.secuscan.in" + assert first["category"] == "Subdomain" + assert first["severity"] == "info" + assert first["metadata"]["subdomain"] == "api.secuscan.in" + + +def test_subdomain_discovery_parser_empty_output_is_deterministic(plugin_manager): + parser = _load_subdomain_discovery_parser() + parsed = parser.parse("") + + assert parsed["findings"] == [] + assert parsed["count"] == 0 + assert parsed["subdomains"] == [] + + +def test_subdomain_discovery_executor_normalizes_parser_fixture(plugin_manager): + parser = _load_subdomain_discovery_parser() + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + + parsed = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + normalized = executor._normalize_parsed_result(plugin, FIXTURE_PATH.read_text(encoding="utf-8"), parsed) + + assert normalized["count"] == 3 + assert len(normalized["findings"]) == 3 + assert normalized["findings"][0]["severity"] == "info" + assert all(f["title"] for f in normalized["findings"]) diff --git a/testing/backend/unit/test_subdomain_takeover_plugin.py b/testing/backend/unit/test_subdomain_takeover_plugin.py new file mode 100644 index 000000000..a7ec31c98 --- /dev/null +++ b/testing/backend/unit/test_subdomain_takeover_plugin.py @@ -0,0 +1,89 @@ +"""Parser and contract coverage for plugins/subdomain_takeover (issue #512).""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.executor import executor +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "subdomain_takeover" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / PLUGIN_ID / "sample_output.txt" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" + + +def _load_subdomain_takeover_parser(): + spec = importlib.util.spec_from_file_location("subdomain_takeover_parser", PARSER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def plugin_manager(setup_test_environment) -> PluginManager: + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + return manager + + +def test_subdomain_takeover_metadata_loads_through_validation_path(plugin_manager): + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "Subdomain Takeover" + assert plugin.category == "exploit" + assert plugin.safety.get("level") == "intrusive" + assert plugin.safety.get("requires_consent") is True + + schema = plugin_manager.get_plugin_schema(PLUGIN_ID) + assert schema is not None + field_ids = {field["id"] for field in schema["fields"]} + assert "target" in field_ids + + +def test_subdomain_takeover_build_command_renders_representative_target(plugin_manager): + target = "secuscan.in" + command = plugin_manager.build_command(PLUGIN_ID, {"target": target}) + + assert command is not None + assert command == ["subfinder", "-d", target, "-silent"] + + +def test_subdomain_takeover_parser_fixture_produces_stable_findings(plugin_manager): + parser = _load_subdomain_takeover_parser() + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + + parsed = parser.parse(raw_output) + assert parsed["count"] == 3 + assert len(parsed["findings"]) == 3 + + detected = parsed["findings"][-1] + assert detected["severity"] == "low" + assert "detected" in detected["description"].lower() + + +def test_subdomain_takeover_parser_empty_output_is_deterministic(plugin_manager): + parser = _load_subdomain_takeover_parser() + parsed = parser.parse("") + + assert parsed["findings"] == [] + assert parsed["count"] == 0 + assert parsed["items"] == [] + + +def test_subdomain_takeover_executor_normalizes_parser_fixture(plugin_manager): + parser = _load_subdomain_takeover_parser() + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + + parsed = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + normalized = executor._normalize_parsed_result(plugin, FIXTURE_PATH.read_text(encoding="utf-8"), parsed) + + assert normalized["count"] == 3 + assert all(f["title"] for f in normalized["findings"]) diff --git a/testing/backend/unit/test_theharvester_plugin.py b/testing/backend/unit/test_theharvester_plugin.py new file mode 100644 index 000000000..d80d814da --- /dev/null +++ b/testing/backend/unit/test_theharvester_plugin.py @@ -0,0 +1,97 @@ +"""Parser and contract coverage for plugins/theharvester (issue #513).""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.executor import executor +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "theharvester" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / PLUGIN_ID / "sample_output.txt" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" + + +def _load_theharvester_parser(): + spec = importlib.util.spec_from_file_location("theharvester_parser", PARSER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def plugin_manager(setup_test_environment) -> PluginManager: + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + return manager + + +def test_theharvester_metadata_loads_through_validation_path(plugin_manager): + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "theHarvester" + assert plugin.category == "recon" + assert plugin.safety.get("level") == "safe" + assert plugin.safety.get("requires_consent") is False + + schema = plugin_manager.get_plugin_schema(PLUGIN_ID) + assert schema is not None + field_ids = {field["id"] for field in schema["fields"]} + assert "target" in field_ids + + +def test_theharvester_build_command_renders_representative_target(plugin_manager): + target = "secuscan.in" + command = plugin_manager.build_command(PLUGIN_ID, {"target": target}) + + assert command is not None + assert command == ["theHarvester", "-d", target, "-b", "all"] + + +def test_theharvester_parser_fixture_produces_stable_findings(plugin_manager): + parser = _load_theharvester_parser() + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + + parsed = parser.parse(raw_output) + assert parsed["count"] == 3 + assert len(parsed["findings"]) == 3 + assert parsed["items"][-1] == "api.secuscan.in found alive" + + first = parsed["findings"][0] + assert first["title"] == "theHarvester Observation" + assert first["category"] == "Recon" + assert first["severity"] == "info" + assert first["metadata"]["raw_line"] == "[*] Target: secuscan.in" + + alive = parsed["findings"][-1] + assert alive["severity"] == "low" + assert "found" in alive["description"].lower() + + +def test_theharvester_parser_empty_output_is_deterministic(plugin_manager): + parser = _load_theharvester_parser() + parsed = parser.parse("") + + assert parsed["findings"] == [] + assert parsed["count"] == 0 + assert parsed["items"] == [] + + +def test_theharvester_executor_normalizes_parser_fixture(plugin_manager): + parser = _load_theharvester_parser() + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + + parsed = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + normalized = executor._normalize_parsed_result(plugin, FIXTURE_PATH.read_text(encoding="utf-8"), parsed) + + assert normalized["count"] == 3 + assert normalized["findings"][-1]["severity"] == "low" + assert all(f["title"] for f in normalized["findings"]) diff --git a/testing/backend/unit/test_uncover_plugin.py b/testing/backend/unit/test_uncover_plugin.py new file mode 100644 index 000000000..353cf4c4f --- /dev/null +++ b/testing/backend/unit/test_uncover_plugin.py @@ -0,0 +1,97 @@ +"""Parser and contract coverage for plugins/uncover (issue #514).""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.executor import executor +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "uncover" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / PLUGIN_ID / "sample_output.txt" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" + + +def _load_uncover_parser(): + spec = importlib.util.spec_from_file_location("uncover_parser", PARSER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def plugin_manager(setup_test_environment) -> PluginManager: + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + return manager + + +def test_uncover_metadata_loads_through_validation_path(plugin_manager): + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "Uncover" + assert plugin.category == "recon" + assert plugin.safety.get("level") == "safe" + assert plugin.safety.get("requires_consent") is False + + schema = plugin_manager.get_plugin_schema(PLUGIN_ID) + assert schema is not None + field_ids = {field["id"] for field in schema["fields"]} + assert {"query", "limit"} <= field_ids + + +def test_uncover_build_command_renders_representative_query(plugin_manager): + query = "org:secuscan.in" + command = plugin_manager.build_command(PLUGIN_ID, {"query": query}) + + assert command is not None + assert command[:4] == ["uncover", "-q", query, "-limit"] + assert "100" in command + assert command[-1] == "-silent" + + +def test_uncover_parser_fixture_produces_stable_findings(plugin_manager): + parser = _load_uncover_parser() + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + + parsed = parser.parse(raw_output) + assert parsed["count"] == 3 + assert len(parsed["findings"]) == 3 + + first = parsed["findings"][0] + assert first["title"] == "Uncover Observation" + assert first["category"] == "Recon" + assert first["severity"] == "info" + assert first["metadata"]["raw_line"] == "https://api.secuscan.in:443" + + found = parsed["findings"][-1] + assert found["severity"] == "low" + assert "found" in found["description"].lower() + + +def test_uncover_parser_empty_output_is_deterministic(plugin_manager): + parser = _load_uncover_parser() + parsed = parser.parse("") + + assert parsed["findings"] == [] + assert parsed["count"] == 0 + assert parsed["items"] == [] + + +def test_uncover_executor_normalizes_parser_fixture(plugin_manager): + parser = _load_uncover_parser() + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + + parsed = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + normalized = executor._normalize_parsed_result(plugin, FIXTURE_PATH.read_text(encoding="utf-8"), parsed) + + assert normalized["count"] == 3 + assert all(f["title"] for f in normalized["findings"]) diff --git a/testing/backend/unit/test_url_fuzzer_2_plugin.py b/testing/backend/unit/test_url_fuzzer_2_plugin.py new file mode 100644 index 000000000..8a0a8642d --- /dev/null +++ b/testing/backend/unit/test_url_fuzzer_2_plugin.py @@ -0,0 +1,136 @@ +import asyncio +import importlib.util +from pathlib import Path + +from backend.secuscan.config import settings +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "url-fuzzer-2" +PLUGIN_DIR = Path(settings.plugins_dir) / PLUGIN_ID + + +def _load_url_fuzzer_parser(): + parser_path = PLUGIN_DIR / "parser.py" + + spec = importlib.util.spec_from_file_location( + "url_fuzzer_2_parser", + parser_path, + ) + + assert spec is not None + assert spec.loader is not None + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + return module + + +def _get_parser_name(plugin): + output = plugin.output + + if isinstance(output, dict): + return output.get("parser") + + return getattr(output, "parser", None) + + +def test_url_fuzzer_2_metadata_loads_through_plugin_manager( + setup_test_environment, +): + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin(PLUGIN_ID) + + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "URL Fuzzer" + assert plugin.category == "recon" + assert _get_parser_name(plugin) == "custom" + + +def test_url_fuzzer_2_command_renders_target_and_wordlist( + setup_test_environment, +): + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + PLUGIN_ID, + { + "target": "https://example.com", + "wordlist": "wordlists/paths.txt", + }, + ) + + assert command == [ + "ffuf", + "-u", + "https://example.com/FUZZ", + "-w", + "wordlists/paths.txt", + "-mc", + "200,204,301,302,307,401,403", + "-s", + ] + + +def test_url_fuzzer_2_parser_normalizes_discovered_paths(): + parser = _load_url_fuzzer_parser() + + result = parser.parse( + "\n".join( + [ + "admin found [Status: 200]", + "login [Status: 301]", + "assets/app.js [Status: 200]", + ] + ) + ) + + assert result["count"] == 3 + assert len(result["findings"]) == 3 + assert result["items"] == [ + "admin found [Status: 200]", + "login [Status: 301]", + "assets/app.js [Status: 200]", + ] + + finding = result["findings"][0] + + assert finding["title"] == "URL Fuzzer Observation" + assert finding["category"] == "Recon" + assert finding["severity"] == "low" + assert finding["description"] == "admin found [Status: 200]" + assert finding["metadata"] == { + "raw_line": "admin found [Status: 200]", + } + + +def test_url_fuzzer_2_parser_keeps_regular_paths_info_severity(): + parser = _load_url_fuzzer_parser() + + result = parser.parse("assets/app.js [Status: 200]") + + assert result["count"] == 1 + assert result["findings"][0]["severity"] == "info" + assert result["findings"][0]["metadata"] == { + "raw_line": "assets/app.js [Status: 200]", + } + + +def test_url_fuzzer_2_parser_ignores_blank_lines_and_caps_output(): + parser = _load_url_fuzzer_parser() + + output = "\n".join( + ["", "admin found [Status: 200]", " "] + + [f"path-{index} [Status: 200]" for index in range(250)] + ) + + result = parser.parse(output) + + assert result["count"] == 200 + assert len(result["findings"]) == 200 + assert result["items"][0] == "admin found [Status: 200]" + assert result["items"][-1] == "path-198 [Status: 200]" \ No newline at end of file diff --git a/testing/backend/unit/test_urlfinder_plugin.py b/testing/backend/unit/test_urlfinder_plugin.py new file mode 100644 index 000000000..4b86d14f1 --- /dev/null +++ b/testing/backend/unit/test_urlfinder_plugin.py @@ -0,0 +1,127 @@ +import asyncio +import importlib.util +from pathlib import Path + +from backend.secuscan.config import settings +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "urlfinder" +PLUGIN_DIR = Path(settings.plugins_dir) / PLUGIN_ID + + +def _load_urlfinder_parser(): + parser_path = PLUGIN_DIR / "parser.py" + + spec = importlib.util.spec_from_file_location( + "urlfinder_parser", + parser_path, + ) + + assert spec is not None + assert spec.loader is not None + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + return module + + +def _get_parser_name(plugin): + output = plugin.output + + if isinstance(output, dict): + return output.get("parser") + + return getattr(output, "parser", None) + + +def test_urlfinder_metadata_loads_through_plugin_manager(setup_test_environment): + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + + plugin = manager.get_plugin(PLUGIN_ID) + + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "urlfinder" + assert plugin.category == "recon" + assert _get_parser_name(plugin) == "custom" + + +def test_urlfinder_command_renders_domain_target(setup_test_environment): + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + + command = manager.build_command( + PLUGIN_ID, + { + "target": "example.com", + }, + ) + + assert command == [ + "urlfinder", + "-d", + "example.com", + "-silent", + ] + + +def test_urlfinder_parser_normalizes_discovered_urls(): + parser = _load_urlfinder_parser() + + result = parser.parse( + "\n".join( + [ + "https://example.com/", + "https://example.com/login", + "https://example.com/open-api", + ] + ) + ) + + assert result["count"] == 3 + assert len(result["findings"]) == 3 + assert result["items"] == [ + "https://example.com/", + "https://example.com/login", + "https://example.com/open-api", + ] + + finding = result["findings"][2] + + assert finding["title"] == "urlfinder Observation" + assert finding["category"] == "Recon" + assert finding["severity"] == "low" + assert finding["description"] == "https://example.com/open-api" + assert finding["metadata"] == { + "raw_line": "https://example.com/open-api", + } + + +def test_urlfinder_parser_keeps_normal_urls_info_severity(): + parser = _load_urlfinder_parser() + + result = parser.parse("https://example.com/about") + + assert result["count"] == 1 + assert result["findings"][0]["severity"] == "info" + assert result["findings"][0]["metadata"] == { + "raw_line": "https://example.com/about", + } + + +def test_urlfinder_parser_ignores_blank_lines_and_caps_output(): + parser = _load_urlfinder_parser() + + output = "\n".join( + ["", "https://example.com/first", " "] + + [f"https://example.com/page-{index}" for index in range(250)] + ) + + result = parser.parse(output) + + assert result["count"] == 200 + assert len(result["findings"]) == 200 + assert result["items"][0] == "https://example.com/first" + assert result["items"][-1] == "https://example.com/page-198" \ No newline at end of file diff --git a/testing/backend/unit/test_validate_doc_anchors.py b/testing/backend/unit/test_validate_doc_anchors.py new file mode 100644 index 000000000..3d5cc519d --- /dev/null +++ b/testing/backend/unit/test_validate_doc_anchors.py @@ -0,0 +1,33 @@ +from scripts.validate_doc_anchors import slugify, validate_file + +def test_detects_missing_anchor(tmp_path): + doc = tmp_path / "sample.md" + + doc.write_text( + "# Heading\n\n[Broken](#missing-anchor)\n", + encoding="utf-8", + ) + + failures = validate_file(doc) + + assert failures == ["missing-anchor"] + + +def test_valid_anchor_passes(tmp_path): + doc = tmp_path / "sample.md" + + doc.write_text( + "# My Heading\n\n[Link](#my-heading)\n", + encoding="utf-8", + ) + + failures = validate_file(doc) + + assert failures == [] + +def test_slugify_heading(): + assert slugify("Incident Response Runbook") == "incident-response-runbook" + + +def test_slugify_special_chars(): + assert slugify("Hello, World!") == "hello-world" diff --git a/testing/backend/unit/test_validate_issue_template_labels.py b/testing/backend/unit/test_validate_issue_template_labels.py new file mode 100644 index 000000000..dd9dd72ee --- /dev/null +++ b/testing/backend/unit/test_validate_issue_template_labels.py @@ -0,0 +1,64 @@ +import os +import sys + +# Add root directory to sys.path so we can import from scripts +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../")) +) + +from scripts.validate_issue_template_labels import ( + extract_front_matter, + parse_labels, + extract_labels_from_front_matter, +) + + +def test_extract_front_matter_returns_content_between_markers(): + content = """--- +labels: type:bug +--- +body +""" + assert extract_front_matter(content).strip() == "labels: type:bug" + + +def test_extract_front_matter_returns_empty_when_missing(): + assert extract_front_matter("no front matter") == "" + + +def test_parse_labels_inline_list(): + labels = parse_labels("[type:bug, area:backend, priority:high]") + assert labels == ["type:bug", "area:backend", "priority:high"] + + +def test_parse_labels_with_quotes(): + labels = parse_labels('"type:testing","area:ci"') + assert labels == ["type:testing", "area:ci"] + + +def test_extract_labels_single_line(): + front_matter = """ +labels: [type:testing, area:ci] +""" + assert extract_labels_from_front_matter(front_matter) == [ + "type:testing", + "area:ci", + ] + + +def test_extract_labels_multiline_yaml_list(): + front_matter = """ +labels: + - type:testing + - area:ci + - priority:medium +""" + assert extract_labels_from_front_matter(front_matter) == [ + "type:testing", + "area:ci", + "priority:medium", + ] + + +def test_extract_labels_returns_empty_when_missing(): + assert extract_labels_from_front_matter("title: example") == [] \ No newline at end of file diff --git a/testing/backend/unit/test_validation.py b/testing/backend/unit/test_validation.py index 69363d56a..10e9b68b6 100644 --- a/testing/backend/unit/test_validation.py +++ b/testing/backend/unit/test_validation.py @@ -1,5 +1,8 @@ import pytest import socket +import ipaddress +from backend.secuscan import validation as validation_module +from backend.secuscan.config import settings from backend.secuscan.validation import ( validate_target, validate_port, validate_port_range, validate_url, sanitize_input, is_safe_path, match_pattern @@ -57,6 +60,38 @@ def fake_getaddrinfo(_host, *_args, **_kwargs): def test_validate_target_safe_mode_blocks_url_ip_literal(): assert validate_target("http://8.8.8.8", safe_mode=True)[0] is False +def test_validate_target_ipv4_with_ipv6_allowed_network_does_not_crash(monkeypatch): + monkeypatch.setattr(settings, "allowed_networks", ["fc00::/7"]) + ok, msg = validate_target("127.0.0.1", safe_mode=True) + + assert ok is False + assert msg == "Target not within allowed networks in safe mode (SecuScan Guardrail)" + + +def test_validate_target_ipv6_with_ipv4_allowed_network_does_not_crash(monkeypatch): + monkeypatch.setattr(settings, "allowed_networks", ["127.0.0.0/8"]) + ok, msg = validate_target("::1", safe_mode=True) + + assert ok is False + assert msg == "Public IPs/networks not allowed in safe mode (SecuScan Guardrail)" + + +def test_validate_target_mixed_allowed_networks_uses_later_same_version_entry(monkeypatch): + monkeypatch.setattr(settings, "allowed_networks", ["fc00::/7", "127.0.0.0/8"]) + ok, msg = validate_target("127.0.0.1", safe_mode=True) + + assert ok is True + assert msg == "" + +def test_validate_target_mixed_allowed_networks_uses_later_same_version_ipv6_entry(monkeypatch): + monkeypatch.setattr(validation_module, "ALLOWED_PRIVATE", [ipaddress.ip_network("fc00::/7")]) + monkeypatch.setattr(settings, "allowed_networks", ["127.0.0.0/8", "fc00::/7"]) + + ok, msg = validate_target("fd00::1", safe_mode=True) + + assert ok is True + assert msg == "" + def test_validate_port(): assert validate_port(80) == (True, "") assert validate_port(65535) == (True, "") @@ -255,3 +290,131 @@ def test_windows_path_other_drive(self): def test_windows_lowercase_drive(self): assert is_filesystem_target(r"c:\users\repo") is True + +def test_validate_command_network_egress_log_only(monkeypatch): + """Test that validate_command_network_egress permits execution with a warning when failure mode is 'log_only'""" + from backend.secuscan.validation import validate_command_network_egress + from backend.secuscan.config import settings + + # Setup monkeypatch for configuration settings + monkeypatch.setattr(settings, "enforce_network_policy", True) + monkeypatch.setattr(settings, "network_policy_failure_mode", "log_only") + + # Command containing a blocked destination (e.g. 10.0.0.1) + command = ["curl", "http://10.0.0.1/"] + + # Under 'log_only' mode, egress violation is logged as a warning but allowed + ok, err = validate_command_network_egress(command, safe_mode=False, plugin_id="test", task_id="test-task") + assert ok is True + assert err == "" + + # Under 'block' mode, it should be denied + monkeypatch.setattr(settings, "network_policy_failure_mode", "block") + ok, err = validate_command_network_egress(command, safe_mode=False, plugin_id="test", task_id="test-task") + assert ok is False + assert "network policy" in err.lower() + + +def test_resolve_and_validate_target_rejects_raw_ip(): + from backend.secuscan.validation import resolve_and_validate_target + ok, err = resolve_and_validate_target("http://10.0.0.1/webhook") + assert ok is False + assert "Raw IP" in err + + +def test_resolve_and_validate_target_rejects_bad_scheme(): + from backend.secuscan.validation import resolve_and_validate_target + ok, err = resolve_and_validate_target("ftp://example.com/hook") + assert ok is False + assert "Scheme" in err + + +def test_resolve_and_validate_target_rejects_blocked_port(monkeypatch): + from backend.secuscan.validation import resolve_and_validate_target + from backend.secuscan.config import settings + monkeypatch.setattr(settings, "notification_allowed_ports", [80, 443]) + ok, err = resolve_and_validate_target("http://example.com:22/webhook") + assert ok is False + assert "Port" in err + + +def test_resolve_and_validate_target_rejects_private_ip(monkeypatch): + from backend.secuscan.validation import resolve_and_validate_target + from backend.secuscan.config import settings + monkeypatch.setattr(settings, "notification_blocked_ip_ranges", ["10.0.0.0/8"]) + + def fake_getaddrinfo(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("10.0.0.5", 80))] + + import socket + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + ok, err = resolve_and_validate_target("http://internal.example.com/hook") + assert ok is False + assert "blocked" in err + + +def test_resolve_and_validate_target_allows_public_ip(monkeypatch): + from backend.secuscan.validation import resolve_and_validate_target + + def fake_getaddrinfo(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("93.184.216.34", 80))] + + import socket + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + ok, err = resolve_and_validate_target("http://example.com/hook") + assert ok is True + assert err == "" + + +class TestValidateWebhookTarget: + """Tests for validate_webhook_target SSRF validation.""" + + def test_rejects_no_hostname(self): + from backend.secuscan.validation import validate_webhook_target + ok, err = validate_webhook_target("not-a-url") + assert ok is False + assert "hostname" in err.lower() + + def test_rejects_private_ip_resolution(self, monkeypatch): + from backend.secuscan.validation import validate_webhook_target + + def fake_getaddrinfo(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("10.0.0.5", 80))] + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + ok, err = validate_webhook_target("http://internal.example.com/hook") + assert ok is False + assert "blocked" in err.lower() + + def test_rejects_metadata_ip_resolution(self, monkeypatch): + from backend.secuscan.validation import validate_webhook_target + + def fake_getaddrinfo(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("169.254.169.254", 80))] + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + ok, err = validate_webhook_target("http://metadata.example.com/hook") + assert ok is False + assert "blocked" in err.lower() + + def test_allows_public_ip_resolution(self, monkeypatch): + from backend.secuscan.validation import validate_webhook_target + + def fake_getaddrinfo(*args, **kwargs): + return [(socket.AF_INET, None, None, None, ("93.184.216.34", 80))] + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + ok, err = validate_webhook_target("http://example.com/hook") + assert ok is True + assert err is None + + def test_rejects_resolution_failure(self, monkeypatch): + from backend.secuscan.validation import validate_webhook_target + + def fake_getaddrinfo(*args, **kwargs): + raise socket.gaierror("No address") + + monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo) + ok, err = validate_webhook_target("http://nonexistent.example.com/hook") + assert ok is False + assert "could not be resolved" in err.lower() diff --git a/testing/backend/unit/test_waf_detector_plugin.py b/testing/backend/unit/test_waf_detector_plugin.py new file mode 100644 index 000000000..7d9a45460 --- /dev/null +++ b/testing/backend/unit/test_waf_detector_plugin.py @@ -0,0 +1,120 @@ +"""Parser and contract coverage for plugins/waf_detector (issue #518).""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.executor import executor +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "waf_detector" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / PLUGIN_ID / "sample_output.txt" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" + + +def _load_waf_detector_parser(): + spec = importlib.util.spec_from_file_location( + "waf_detector_parser", + PARSER_PATH, + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def plugin_manager(setup_test_environment) -> PluginManager: + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + return manager + + +def test_waf_detector_metadata_loads_through_validation_path(plugin_manager): + plugin = plugin_manager.get_plugin(PLUGIN_ID) + + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "WAF Detection Scanner" + assert plugin.category == "robots" + assert plugin.safety.get("level") == "safe" + assert plugin.safety.get("requires_consent") is False + + schema = plugin_manager.get_plugin_schema(PLUGIN_ID) + assert schema is not None + + field_ids = {field["id"] for field in schema["fields"]} + assert "target" in field_ids + + +def test_waf_detector_build_command_renders_representative_target(plugin_manager): + target = "https://secuscan.in" + + command = plugin_manager.build_command( + PLUGIN_ID, + {"target": target}, + ) + + assert command is not None + assert command == ["wafw00f", target] + + +def test_waf_detector_parser_fixture_produces_stable_findings(plugin_manager): + parser = _load_waf_detector_parser() + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + + parsed = parser.parse(raw_output) + + assert parsed["count"] == 3 + assert len(parsed["findings"]) == 3 + + assert parsed["items"] == [ + "Checking https://secuscan.in", + "Detected Cloudflare WAF", + "Scan completed", + ] + + finding = parsed["findings"][1] + + assert finding["title"] == "Recon/Scan Observation" + assert finding["category"] == "Security Scan" + assert finding["severity"] == "low" + assert finding["metadata"]["raw"] == "Detected Cloudflare WAF" + + +def test_waf_detector_parser_empty_output_is_deterministic(plugin_manager): + parser = _load_waf_detector_parser() + + parsed = parser.parse("") + + assert parsed["findings"] == [] + assert parsed["count"] == 0 + assert parsed["items"] == [] + + +def test_waf_detector_executor_normalizes_parser_fixture(plugin_manager): + parser = _load_waf_detector_parser() + + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + parsed = parser.parse(raw_output) + + normalized = executor._normalize_parsed_result( + plugin, + raw_output, + parsed, + ) + + assert normalized["count"] == 3 + assert len(normalized["findings"]) == 3 + assert normalized["findings"][1]["severity"] == "low" + + assert all(f["title"] for f in normalized["findings"]) + assert all(f["category"] for f in normalized["findings"]) \ No newline at end of file diff --git a/testing/backend/unit/test_web_scanner_passive.py b/testing/backend/unit/test_web_scanner_passive.py new file mode 100644 index 000000000..e9a024fce --- /dev/null +++ b/testing/backend/unit/test_web_scanner_passive.py @@ -0,0 +1,38 @@ +from backend.secuscan.scanners.web_scanner import WebScanner + + +def test_web_scanner_passive_findings_cover_headers_cookies_forms_and_paths(): + scanner = WebScanner(task_id="test-task", db=None) + crawl = { + "final_url": "http://example.com/login", + "seed_url": "http://example.com", + "scheme": "http", + "headers": {"server": "nginx"}, + "set_cookie_headers": ["sessionid=abc123; Path=/"], + "forms": [ + { + "action": "http://example.com/login", + "method": "post", + "state_changing": True, + "has_csrf_token": False, + "password_fields": 1, + } + ], + "path_hints": [ + {"url": "http://example.com/admin", "kind": "admin"}, + {"url": "http://example.com/docs", "kind": "docs"}, + ], + "cms_hints": ["wordpress"], + "api_hints": ["http://example.com/openapi.json"], + "pages": [], + } + + findings = scanner._build_passive_findings("http://example.com", crawl) + titles = {item["title"] for item in findings} + + assert "Missing Content-Security-Policy" in titles + assert "Insecure Cookie Attributes on sessionid" in titles + assert "State-Changing Form Missing CSRF Indicators: http://example.com/login" in titles + assert "Credential Form Exposed over Non-HTTPS: http://example.com/login" in titles + assert "Administrative Paths Exposed" in titles + assert "CMS Fingerprint Detected: Wordpress" in titles diff --git a/testing/backend/unit/test_whois_lookup_parser.py b/testing/backend/unit/test_whois_lookup_parser.py new file mode 100644 index 000000000..3ec110c49 --- /dev/null +++ b/testing/backend/unit/test_whois_lookup_parser.py @@ -0,0 +1,25 @@ +from plugins.whois_lookup.parser import parse + + +def test_empty_output_returns_default_record(): + result = parse("") + + assert "findings" in result + assert "rows" in result + assert "detail" in result + + assert len(result["findings"]) == 1 + assert result["detail"]["registrar"] == "Unknown" + + +def test_rate_limited_output_returns_default_record(): + output = "WHOIS LIMIT EXCEEDED" + + result = parse(output) + + assert "findings" in result + assert "rows" in result + assert "detail" in result + + assert len(result["findings"]) == 1 + assert result["detail"]["registrar"] == "Unknown" diff --git a/testing/backend/unit/test_workflow_history.py b/testing/backend/unit/test_workflow_history.py new file mode 100644 index 000000000..8c719bf7e --- /dev/null +++ b/testing/backend/unit/test_workflow_history.py @@ -0,0 +1,342 @@ +""" +Tests for database-backed workflow run history and rollback (issue #225). + +Covers: + - snapshot_workflow_version creates a version row with incremented version_number. + - version_number auto-increments per workflow (not globally). + - get_workflow_versions returns versions newest-first. + - get_workflow_version returns the correct version or None. + - record_workflow_run creates a run row with the correct fields. + - get_workflow_runs returns paginated runs newest-first. + - Rollback restores workflow definition from a prior version. + - Rollback creates a new version snapshot tagged with rollback metadata. + - Rollback to non-existent version returns 404. + - update_workflow snapshots a new version on every PATCH. + - run_workflow_once records a run row with the active version. + - Runs are isolated per workflow (different workflow_ids). + - get_workflow_runs pagination works correctly. + - Version definitions round-trip through JSON faithfully. +""" + +import json +import uuid +import pytest +import pytest_asyncio + +from backend.secuscan.database import Database + +_STEPS = [{"plugin_id": "http_inspector", "inputs": {"url": "http://example.com"}}] + + +def _wf_id(): + return uuid.uuid4().hex + + +@pytest_asyncio.fixture +async def db(tmp_path): + instance = Database(str(tmp_path / "test.db")) + await instance.connect() + yield instance + await instance.disconnect() + + +async def _insert_workflow(db: Database, name: str = "test-wf") -> str: + wf_id = _wf_id() + await db.execute( + "INSERT INTO workflows (id, name, steps_json) VALUES (?, ?, ?)", + (wf_id, name, json.dumps(_STEPS)), + ) + return wf_id + + +class TestSnapshotWorkflowVersion: + @pytest.mark.asyncio + async def test_creates_version_row(self, db): + wf_id = await _insert_workflow(db) + v = await db.snapshot_workflow_version(wf_id, "test-wf", None, True, _STEPS) + assert v["version_number"] == 1 + assert v["workflow_id"] == wf_id + + @pytest.mark.asyncio + async def test_version_number_increments(self, db): + wf_id = await _insert_workflow(db) + v1 = await db.snapshot_workflow_version(wf_id, "wf", None, True, _STEPS) + v2 = await db.snapshot_workflow_version(wf_id, "wf-v2", None, True, _STEPS) + assert v1["version_number"] == 1 + assert v2["version_number"] == 2 + + @pytest.mark.asyncio + async def test_version_numbers_are_per_workflow(self, db): + wf_a = await _insert_workflow(db, "wf-a") + wf_b = await _insert_workflow(db, "wf-b") + va = await db.snapshot_workflow_version(wf_a, "wf-a", None, True, _STEPS) + vb = await db.snapshot_workflow_version(wf_b, "wf-b", None, True, _STEPS) + assert va["version_number"] == 1 + assert vb["version_number"] == 1 + + @pytest.mark.asyncio + async def test_definition_stored_correctly(self, db): + wf_id = await _insert_workflow(db) + custom_steps = [{"plugin_id": "port_scanner", "inputs": {"target": "192.168.1.1"}}] + v = await db.snapshot_workflow_version(wf_id, "custom", 3600, False, custom_steps) + assert v["definition"]["name"] == "custom" + assert v["definition"]["schedule_seconds"] == 3600 + assert v["definition"]["enabled"] is False + assert v["definition"]["steps"] == custom_steps + + @pytest.mark.asyncio + async def test_created_by_stored(self, db): + wf_id = await _insert_workflow(db) + v = await db.snapshot_workflow_version(wf_id, "wf", None, True, _STEPS, created_by="rollback_to_v2") + rows = await db.fetchall("SELECT * FROM workflow_versions WHERE workflow_id = ?", (wf_id,)) + assert rows[0]["created_by"] == "rollback_to_v2" + + +class TestGetWorkflowVersions: + @pytest.mark.asyncio + async def test_returns_versions_newest_first(self, db): + wf_id = await _insert_workflow(db) + for i in range(4): + await db.snapshot_workflow_version(wf_id, f"wf-v{i}", None, True, _STEPS) + versions = await db.get_workflow_versions(wf_id) + assert [v["version_number"] for v in versions] == [4, 3, 2, 1] + + @pytest.mark.asyncio + async def test_empty_for_workflow_with_no_versions(self, db): + wf_id = await _insert_workflow(db) + versions = await db.get_workflow_versions(wf_id) + assert versions == [] + + @pytest.mark.asyncio + async def test_isolated_per_workflow(self, db): + wf_a = await _insert_workflow(db, "wf-a") + wf_b = await _insert_workflow(db, "wf-b") + await db.snapshot_workflow_version(wf_a, "wf-a", None, True, _STEPS) + await db.snapshot_workflow_version(wf_a, "wf-a", None, True, _STEPS) + await db.snapshot_workflow_version(wf_b, "wf-b", None, True, _STEPS) + versions_a = await db.get_workflow_versions(wf_a) + versions_b = await db.get_workflow_versions(wf_b) + assert len(versions_a) == 2 + assert len(versions_b) == 1 + + +class TestGetWorkflowVersion: + @pytest.mark.asyncio + async def test_returns_correct_version(self, db): + wf_id = await _insert_workflow(db) + await db.snapshot_workflow_version(wf_id, "v1-name", None, True, _STEPS) + await db.snapshot_workflow_version(wf_id, "v2-name", 60, False, []) + v1 = await db.get_workflow_version(wf_id, 1) + v2 = await db.get_workflow_version(wf_id, 2) + assert v1["definition"]["name"] == "v1-name" + assert v2["definition"]["name"] == "v2-name" + assert v2["definition"]["enabled"] is False + + @pytest.mark.asyncio + async def test_returns_none_for_missing_version(self, db): + wf_id = await _insert_workflow(db) + result = await db.get_workflow_version(wf_id, 99) + assert result is None + + +class TestRecordWorkflowRun: + @pytest.mark.asyncio + async def test_creates_run_row(self, db): + wf_id = await _insert_workflow(db) + v = await db.snapshot_workflow_version(wf_id, "wf", None, True, _STEPS) + run_id = await db.record_workflow_run(wf_id, v["id"], v["version_number"], ["task-1", "task-2"]) + rows = await db.fetchall("SELECT * FROM workflow_runs WHERE workflow_id = ?", (wf_id,)) + assert len(rows) == 1 + assert rows[0]["id"] == run_id + assert json.loads(rows[0]["task_ids_json"]) == ["task-1", "task-2"] + assert rows[0]["version_number"] == 1 + + @pytest.mark.asyncio + async def test_triggered_by_stored(self, db): + wf_id = await _insert_workflow(db) + await db.record_workflow_run(wf_id, None, None, [], triggered_by="scheduler") + rows = await db.fetchall("SELECT * FROM workflow_runs WHERE workflow_id = ?", (wf_id,)) + assert rows[0]["triggered_by"] == "scheduler" + + @pytest.mark.asyncio + async def test_default_status_is_queued(self, db): + wf_id = await _insert_workflow(db) + await db.record_workflow_run(wf_id, None, None, []) + rows = await db.fetchall("SELECT * FROM workflow_runs WHERE workflow_id = ?", (wf_id,)) + assert rows[0]["status"] == "queued" + + +class TestGetWorkflowRuns: + @pytest.mark.asyncio + async def test_returns_runs_newest_first(self, db): + wf_id = await _insert_workflow(db) + for _ in range(4): + await db.record_workflow_run(wf_id, None, None, []) + result = await db.get_workflow_runs(wf_id) + assert result["total"] == 4 + assert len(result["runs"]) == 4 + + @pytest.mark.asyncio + async def test_pagination(self, db): + wf_id = await _insert_workflow(db) + for _ in range(6): + await db.record_workflow_run(wf_id, None, None, []) + p1 = await db.get_workflow_runs(wf_id, limit=3, offset=0) + p2 = await db.get_workflow_runs(wf_id, limit=3, offset=3) + assert len(p1["runs"]) == 3 + assert len(p2["runs"]) == 3 + ids_p1 = {r["id"] for r in p1["runs"]} + ids_p2 = {r["id"] for r in p2["runs"]} + assert ids_p1.isdisjoint(ids_p2) + + @pytest.mark.asyncio + async def test_task_ids_deserialised(self, db): + wf_id = await _insert_workflow(db) + await db.record_workflow_run(wf_id, None, None, ["task-x", "task-y"]) + result = await db.get_workflow_runs(wf_id) + assert result["runs"][0]["task_ids"] == ["task-x", "task-y"] + + @pytest.mark.asyncio + async def test_isolated_per_workflow(self, db): + wf_a = await _insert_workflow(db, "wf-a") + wf_b = await _insert_workflow(db, "wf-b") + for _ in range(3): + await db.record_workflow_run(wf_a, None, None, []) + await db.record_workflow_run(wf_b, None, None, []) + result_a = await db.get_workflow_runs(wf_a) + result_b = await db.get_workflow_runs(wf_b) + assert result_a["total"] == 3 + assert result_b["total"] == 1 + + @pytest.mark.asyncio + async def test_empty_for_workflow_with_no_runs(self, db): + wf_id = await _insert_workflow(db) + result = await db.get_workflow_runs(wf_id) + assert result["total"] == 0 + assert result["runs"] == [] + + +class TestRollbackIntegration: + @pytest.mark.asyncio + async def test_rollback_restores_definition(self, db): + wf_id = await _insert_workflow(db) + v1 = await db.snapshot_workflow_version(wf_id, "original-name", None, True, _STEPS) + await db.execute("UPDATE workflows SET name = 'changed-name' WHERE id = ?", (wf_id,)) + await db.snapshot_workflow_version(wf_id, "changed-name", 60, True, []) + target = await db.get_workflow_version(wf_id, v1["version_number"]) + assert target is not None + defn = target["definition"] + await db.execute( + "UPDATE workflows SET name = ?, steps_json = ?, schedule_seconds = ? WHERE id = ?", + (defn["name"], json.dumps(defn["steps"]), defn.get("schedule_seconds"), wf_id), + ) + restored = await db.fetchone("SELECT * FROM workflows WHERE id = ?", (wf_id,)) + assert restored["name"] == "original-name" + + @pytest.mark.asyncio + async def test_rollback_creates_new_version(self, db): + wf_id = await _insert_workflow(db) + v1 = await db.snapshot_workflow_version(wf_id, "v1", None, True, _STEPS) + await db.snapshot_workflow_version(wf_id, "v2", None, True, _STEPS) + new_v = await db.snapshot_workflow_version( + wf_id, "v1", None, True, _STEPS, created_by=f"rollback_to_v{v1['version_number']}" + ) + assert new_v["version_number"] == 3 + versions = await db.get_workflow_versions(wf_id) + assert versions[0]["created_by"] == f"rollback_to_v{v1['version_number']}" + + @pytest.mark.asyncio + async def test_five_version_sequence_correct(self, db): + wf_id = await _insert_workflow(db) + for i in range(5): + await db.snapshot_workflow_version(wf_id, f"wf-v{i+1}", i * 60, i % 2 == 0, _STEPS) + versions = await db.get_workflow_versions(wf_id) + assert len(versions) == 5 + assert versions[0]["version_number"] == 5 + assert versions[4]["version_number"] == 1 + + +class TestWorkflowRunLifecycle: + @pytest.mark.asyncio + async def test_finalize_run_marks_completed(self, db): + wf_id = await _insert_workflow(db) + run_id = await db.record_workflow_run(wf_id, None, None, []) + await db.finalize_workflow_run(run_id, "completed") + rows = await db.fetchall("SELECT * FROM workflow_runs WHERE id = ?", (run_id,)) + assert rows[0]["status"] == "completed" + assert rows[0]["completed_at"] is not None + + @pytest.mark.asyncio + async def test_finalize_run_marks_failed_with_message(self, db): + wf_id = await _insert_workflow(db) + run_id = await db.record_workflow_run(wf_id, None, None, []) + await db.finalize_workflow_run(run_id, "failed", "Task crashed") + rows = await db.fetchall("SELECT * FROM workflow_runs WHERE id = ?", (run_id,)) + assert rows[0]["status"] == "failed" + assert rows[0]["error_message"] == "Task crashed" + + @pytest.mark.asyncio + async def test_finalize_run_marks_cancelled(self, db): + wf_id = await _insert_workflow(db) + run_id = await db.record_workflow_run(wf_id, None, None, []) + await db.finalize_workflow_run(run_id, "cancelled") + rows = await db.fetchall("SELECT * FROM workflow_runs WHERE id = ?", (run_id,)) + assert rows[0]["status"] == "cancelled" + + @pytest.mark.asyncio + async def test_check_workflow_run_tasks_empty_completes(self, db): + wf_id = await _insert_workflow(db) + run_id = await db.record_workflow_run(wf_id, None, None, []) + status = await db.check_workflow_run_tasks(run_id) + assert status == "completed" + + @pytest.mark.asyncio + async def test_check_workflow_run_tasks_in_progress_returns_none(self, db): + wf_id = await _insert_workflow(db) + await db.execute( + "INSERT INTO tasks (id, owner_id, plugin_id, tool_name, target, status, inputs_json) " + "VALUES ('t1', 'u1', 'p1', 'p1', 'tgt', 'running', '{}')" + ) + run_id = await db.record_workflow_run(wf_id, None, None, ["t1"]) + status = await db.check_workflow_run_tasks(run_id) + assert status is None + + @pytest.mark.asyncio + async def test_check_workflow_run_tasks_all_completed(self, db): + wf_id = await _insert_workflow(db) + await db.execute( + "INSERT INTO tasks (id, owner_id, plugin_id, tool_name, target, status, inputs_json) " + "VALUES ('t2', 'u1', 'p1', 'p1', 'tgt', 'completed', '{}')" + ) + run_id = await db.record_workflow_run(wf_id, None, None, ["t2"]) + status = await db.check_workflow_run_tasks(run_id) + assert status == "completed" + + @pytest.mark.asyncio + async def test_check_workflow_run_tasks_one_failed(self, db): + wf_id = await _insert_workflow(db) + await db.execute( + "INSERT INTO tasks (id, owner_id, plugin_id, tool_name, target, status, inputs_json) " + "VALUES ('t3', 'u1', 'p1', 'p1', 'tgt', 'failed', '{}')" + ) + run_id = await db.record_workflow_run(wf_id, None, None, ["t3"]) + status = await db.check_workflow_run_tasks(run_id) + assert status == "failed" + + @pytest.mark.asyncio + async def test_workflow_run_history_preserves_triggered_by(self, db): + wf_id = await _insert_workflow(db) + + run_id = await db.record_workflow_run( + wf_id, + None, + None, + [], + triggered_by="scheduler:user:test-owner", + ) + + history = await db.get_workflow_runs(wf_id) + + assert history["total"] == 1 + assert history["runs"][0]["id"] == run_id + assert history["runs"][0]["triggered_by"] == "scheduler:user:test-owner" diff --git a/testing/backend/unit/test_workflow_scheduler_security.py b/testing/backend/unit/test_workflow_scheduler_security.py new file mode 100644 index 000000000..a7b93cf52 --- /dev/null +++ b/testing/backend/unit/test_workflow_scheduler_security.py @@ -0,0 +1,238 @@ +""" +Tests for workflow scheduler route-level security controls. + +Verifies that the scheduler path applies target validation, rate limiting, +and concurrency controls consistent with the API path. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from backend.secuscan.workflows import WorkflowScheduler +from backend.secuscan.ratelimit import WorkflowRateLimiter + + +@pytest.fixture +def scheduler(): + return WorkflowScheduler() + + +@pytest.fixture +def rate_limiter(): + return WorkflowRateLimiter() + + +# --------------------------------------------------------------------------- +# WorkflowRateLimiter unit tests +# --------------------------------------------------------------------------- + +class TestWorkflowRateLimiter: + @pytest.mark.asyncio + async def test_allows_first_run(self, rate_limiter): + ok, msg = await rate_limiter.check_workflow_rate_limit("wf-1", 60) + assert ok is True + assert msg == "" + + @pytest.mark.asyncio + async def test_blocks_second_run_within_interval(self, rate_limiter): + await rate_limiter.check_workflow_rate_limit("wf-1", 60) + ok, msg = await rate_limiter.check_workflow_rate_limit("wf-1", 60) + assert ok is False + assert "rate limited" in msg.lower() + + @pytest.mark.asyncio + async def test_allows_different_workflows_independently(self, rate_limiter): + await rate_limiter.check_workflow_rate_limit("wf-1", 60) + ok, msg = await rate_limiter.check_workflow_rate_limit("wf-2", 60) + assert ok is True + + +# --------------------------------------------------------------------------- +# WorkflowScheduler._run_workflow security control tests +# --------------------------------------------------------------------------- +# Note: _run_workflow() uses local imports inside the function body +# (e.g., "from .plugins import get_plugin_manager"), so we patch the +# original module paths rather than the local names. + +class TestSchedulerSecurityControls: + @pytest.mark.asyncio + async def test_skips_step_when_plugin_not_found(self, scheduler): + steps = [{"plugin_id": "nonexistent-plugin", "inputs": {}}] + with patch("backend.secuscan.workflows.get_db", new_callable=AsyncMock) as mock_get_db, \ + patch("backend.secuscan.plugins.get_plugin_manager") as mock_get_pm: + + mock_db = AsyncMock() + mock_get_db.return_value = mock_db + mock_pm = MagicMock() + mock_pm.get_plugin.return_value = None + mock_get_pm.return_value = mock_pm + + await scheduler._run_workflow("wf-1", steps) + mock_db.record_workflow_run.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_step_when_target_validation_fails(self, scheduler): + steps = [{ + "plugin_id": "nmap", + "inputs": {"target": "invalid-target"}, + }] + with patch("backend.secuscan.workflows.get_db", new_callable=AsyncMock), \ + patch("backend.secuscan.plugins.get_plugin_manager") as mock_get_pm, \ + patch("backend.secuscan.validation.validate_target", return_value=(False, "Target not allowed")) as mock_val: + + mock_pm = MagicMock() + plugin = MagicMock() + plugin.category = "network" + plugin.safety = {"rate_limit": {"max_per_hour": 50}} + plugin.fields = [] + mock_pm.get_plugin.return_value = plugin + mock_get_pm.return_value = mock_pm + + await scheduler._run_workflow("wf-1", steps) + mock_val.assert_called_once() + + @pytest.mark.asyncio + async def test_skips_step_when_rate_limit_exceeded(self, scheduler): + steps = [{ + "plugin_id": "nmap", + "inputs": {"target": "example.com"}, + }] + with patch("backend.secuscan.workflows.get_db", new_callable=AsyncMock), \ + patch("backend.secuscan.plugins.get_plugin_manager") as mock_get_pm, \ + patch("backend.secuscan.validation.validate_target", return_value=(True, "")), \ + patch("backend.secuscan.ratelimit.rate_limiter.can_execute", new_callable=AsyncMock) as mock_rate: + + mock_pm = MagicMock() + plugin = MagicMock() + plugin.category = "network" + plugin.safety = {"rate_limit": {"max_per_hour": 50}} + plugin.fields = [] + mock_pm.get_plugin.return_value = plugin + mock_get_pm.return_value = mock_pm + mock_rate.return_value = (False, "Rate limit exceeded") + + await scheduler._run_workflow("wf-1", steps) + mock_rate.assert_called_once() + + @pytest.mark.asyncio + async def test_applies_safe_mode_consistently(self, scheduler): + steps = [{ + "plugin_id": "nmap", + "inputs": {"target": "example.com", "safe_mode": False}, + }] + with patch("backend.secuscan.workflows.get_db", new_callable=AsyncMock), \ + patch("backend.secuscan.plugins.get_plugin_manager") as mock_get_pm, \ + patch("backend.secuscan.validation.validate_target", return_value=(True, "")), \ + patch("backend.secuscan.ratelimit.rate_limiter.can_execute", return_value=(True, "")), \ + patch("backend.secuscan.ratelimit.concurrent_limiter.acquire", return_value=(True, "")), \ + patch("backend.secuscan.executor.executor.create_task", new_callable=AsyncMock, return_value="task-1") as mock_create: + + mock_pm = MagicMock() + plugin = MagicMock() + plugin.category = "network" + plugin.safety = {"rate_limit": {"max_per_hour": 50}} + plugin.fields = [] + mock_pm.get_plugin.return_value = plugin + mock_get_pm.return_value = mock_pm + + await scheduler._run_workflow("wf-1", steps) + args, kwargs = mock_create.call_args + inputs = args[1] if len(args) > 1 else kwargs.get("inputs", {}) + assert "safe_mode" in inputs + assert inputs["safe_mode"] is True + + @pytest.mark.asyncio + async def test_acquires_concurrency_slot(self, scheduler): + steps = [{ + "plugin_id": "nmap", + "inputs": {"target": "example.com"}, + }] + with patch("backend.secuscan.workflows.get_db", new_callable=AsyncMock), \ + patch("backend.secuscan.plugins.get_plugin_manager") as mock_get_pm, \ + patch("backend.secuscan.validation.validate_target", return_value=(True, "")), \ + patch("backend.secuscan.ratelimit.rate_limiter.can_execute", return_value=(True, "")), \ + patch("backend.secuscan.ratelimit.concurrent_limiter.acquire", new_callable=AsyncMock) as mock_acquire, \ + patch("backend.secuscan.executor.executor.create_task", new_callable=AsyncMock, return_value="task-1"): + + mock_pm = MagicMock() + plugin = MagicMock() + plugin.category = "network" + plugin.safety = {"rate_limit": {"max_per_hour": 50}} + plugin.fields = [] + mock_pm.get_plugin.return_value = plugin + mock_get_pm.return_value = mock_pm + mock_acquire.return_value = (True, "") + + await scheduler._run_workflow("wf-1", steps) + mock_acquire.assert_called_once_with("task-1") + + @pytest.mark.asyncio + async def test_skips_step_when_concurrency_limit_reached(self, scheduler): + steps = [{ + "plugin_id": "nmap", + "inputs": {"target": "example.com"}, + }] + with patch("backend.secuscan.workflows.get_db", new_callable=AsyncMock), \ + patch("backend.secuscan.plugins.get_plugin_manager") as mock_get_pm, \ + patch("backend.secuscan.validation.validate_target", return_value=(True, "")), \ + patch("backend.secuscan.ratelimit.rate_limiter.can_execute", return_value=(True, "")), \ + patch("backend.secuscan.ratelimit.concurrent_limiter.acquire", return_value=(False, "Concurrency limit reached")), \ + patch("backend.secuscan.executor.executor.create_task", new_callable=AsyncMock, return_value="task-1"), \ + patch("backend.secuscan.executor.executor.mark_task_failed", new_callable=AsyncMock) as mock_fail: + + mock_pm = MagicMock() + plugin = MagicMock() + plugin.category = "network" + plugin.safety = {"rate_limit": {"max_per_hour": 50}} + plugin.fields = [] + mock_pm.get_plugin.return_value = plugin + mock_get_pm.return_value = mock_pm + + await scheduler._run_workflow("wf-1", steps) + mock_fail.assert_called_once() + + +# --------------------------------------------------------------------------- +# WorkflowScheduler.tick rate limit integration +# --------------------------------------------------------------------------- + +class TestTickRateLimiting: + @pytest.mark.asyncio + async def test_tick_applies_workflow_rate_limiter(self, scheduler): + db_mock = AsyncMock() + db_mock.fetchall.return_value = [{ + "id": "wf-1", + "name": "test", + "owner_id": "default", + "schedule_seconds": 60, + "last_run_at": None, + "steps_json": "[]", + }] + with patch("backend.secuscan.workflows.get_db", return_value=db_mock), \ + patch.object(scheduler, "_run_workflow", new_callable=AsyncMock) as mock_run, \ + patch("backend.secuscan.workflows.workflow_rate_limiter.check_workflow_rate_limit", new_callable=AsyncMock) as mock_rate: + + mock_rate.return_value = (True, "") + await scheduler.tick() + mock_rate.assert_called_once_with("wf-1", 60) + mock_run.assert_called_once() + + @pytest.mark.asyncio + async def test_tick_skips_rate_limited_workflow(self, scheduler): + db_mock = AsyncMock() + db_mock.fetchall.return_value = [{ + "id": "wf-1", + "name": "test", + "owner_id": "default", + "schedule_seconds": 60, + "last_run_at": None, + "steps_json": "[]", + }] + with patch("backend.secuscan.workflows.get_db", return_value=db_mock), \ + patch.object(scheduler, "_run_workflow", new_callable=AsyncMock) as mock_run, \ + patch("backend.secuscan.workflows.workflow_rate_limiter.check_workflow_rate_limit", new_callable=AsyncMock) as mock_rate: + + mock_rate.return_value = (False, "Workflow rate limited: wait 30s between runs") + await scheduler.tick() + mock_rate.assert_called_once() + mock_run.assert_not_called() diff --git a/testing/backend/unit/test_workflows_scheduler.py b/testing/backend/unit/test_workflows_scheduler.py index e8236f521..fbf3b6b9a 100644 --- a/testing/backend/unit/test_workflows_scheduler.py +++ b/testing/backend/unit/test_workflows_scheduler.py @@ -6,7 +6,9 @@ """ from datetime import datetime, timezone, timedelta +import asyncio import pytest +from unittest.mock import MagicMock, patch, AsyncMock from backend.secuscan.workflows import WorkflowScheduler @@ -86,4 +88,127 @@ def test_offset_aware_iso_string_still_works(scheduler): def test_empty_string_treated_as_no_last_run(scheduler): """Empty string last_run_at should behave like None → run.""" - assert scheduler._should_run(_now(), "", 3600) is True \ No newline at end of file + assert scheduler._should_run(_now(), "", 3600) is True + + +# --------------------------------------------------------------------------- +# _run_workflow error-path tests +# --------------------------------------------------------------------------- + +def _make_mock_db(): + mock_db = MagicMock() + mock_db.fetchone = AsyncMock(return_value=None) + mock_db.fetchall = AsyncMock(return_value=[]) + return mock_db + + +def _make_mock_plugin(category="scan", safety=None): + p = MagicMock() + p.category = category + p.safety = safety or {} + return p + + +async def _run_workflow_with_mocks(scheduler, steps, mock_db, plugin, validate_result=(True, "")): + """Run _run_workflow with patched dependencies, return mock_executor for assertions.""" + mock_pm = MagicMock() + mock_pm.get_plugin.return_value = plugin + + mock_executor = MagicMock() + mock_executor.create_task = AsyncMock(return_value="tid-test") + mock_executor.mark_task_failed = AsyncMock() + + mock_concurrent_limiter = MagicMock() + mock_concurrent_limiter.acquire = AsyncMock(return_value=(True, "")) + + mock_engine = MagicMock() + mock_engine.check_access.return_value = (True, "", None) + + with patch("backend.secuscan.workflows.get_db", new_callable=AsyncMock, return_value=mock_db): + with patch("backend.secuscan.plugins.get_plugin_manager", return_value=mock_pm): + with patch("backend.secuscan.validation.validate_target", return_value=validate_result): + with patch("backend.secuscan.network_policy.get_policy_engine", return_value=mock_engine): + with patch("backend.secuscan.workflows.executor", mock_executor): + with patch("backend.secuscan.workflows.concurrent_limiter", mock_concurrent_limiter): + with patch("backend.secuscan.workflows.get_target_policy", new_callable=AsyncMock, return_value=None): + with patch("backend.secuscan.workflows.normalize_execution_context", return_value={}): + with patch("backend.secuscan.workflows.get_request_id", return_value="req-1"): + await scheduler._run_workflow("wf-1", steps) + + return mock_executor + + +class TestRunWorkflowErrorPaths: + def test_skips_missing_plugin(self): + """When plugin not found, _run_workflow skips the step and does not create a task.""" + scheduler = WorkflowScheduler() + mock_db = _make_mock_db() + mock_executor = asyncio.run( + _run_workflow_with_mocks(scheduler, [{"plugin_id": "nonexistent", "inputs": {}}], mock_db, plugin=None) + ) + mock_executor.create_task.assert_not_called() + + def test_skips_invalid_target(self): + """When target validation fails, _run_workflow skips the step and does not create a task.""" + scheduler = WorkflowScheduler() + mock_db = _make_mock_db() + mock_executor = asyncio.run( + _run_workflow_with_mocks( + scheduler, + [{"plugin_id": "nmap", "inputs": {"target": "bad-target"}}], + mock_db, + plugin=_make_mock_plugin(category="scan"), + validate_result=(False, "Invalid target"), + ) + ) + mock_executor.create_task.assert_not_called() + + def test_skips_target_validation_timeout(self): + """When target validation times out, _run_workflow skips the step without creating a task.""" + scheduler = WorkflowScheduler() + mock_db = _make_mock_db() + + mock_pm = MagicMock() + mock_pm.get_plugin.return_value = _make_mock_plugin(category="scan") + + mock_executor = MagicMock() + mock_executor.create_task = AsyncMock(return_value="tid-test") + + mock_concurrent_limiter = MagicMock() + mock_concurrent_limiter.acquire = AsyncMock(return_value=(True, "")) + + mock_engine = MagicMock() + mock_engine.check_access.return_value = (True, "", None) + + def sync_timeout_validate(*args, **kwargs): + raise TimeoutError() + + async def run_test(): + with patch("backend.secuscan.workflows.get_db", new_callable=AsyncMock, return_value=mock_db): + with patch("backend.secuscan.plugins.get_plugin_manager", return_value=mock_pm): + with patch("backend.secuscan.validation.validate_target", side_effect=sync_timeout_validate): + with patch("backend.secuscan.network_policy.get_policy_engine", return_value=mock_engine): + with patch("backend.secuscan.workflows.executor", mock_executor): + with patch("backend.secuscan.workflows.concurrent_limiter", mock_concurrent_limiter): + with patch("backend.secuscan.workflows.get_target_policy", new_callable=AsyncMock, return_value=None): + with patch("backend.secuscan.workflows.normalize_execution_context", return_value={}): + with patch("backend.secuscan.workflows.get_request_id", return_value="req-1"): + await scheduler._run_workflow("wf-1", [{"plugin_id": "nmap", "inputs": {"target": "bad"}}]) + + asyncio.run(run_test()) + mock_executor.create_task.assert_not_called() + + def test_creates_task_when_valid(self): + """When plugin found and target valid, _run_workflow creates a task.""" + scheduler = WorkflowScheduler() + mock_db = _make_mock_db() + mock_executor = asyncio.run( + _run_workflow_with_mocks( + scheduler, + [{"plugin_id": "nmap", "inputs": {"target": "127.0.0.1"}}], + mock_db, + plugin=_make_mock_plugin(category="scan"), + validate_result=(True, ""), + ) + ) + mock_executor.create_task.assert_called_once() diff --git a/testing/backend/unit/test_xss_exploiter_plugin.py b/testing/backend/unit/test_xss_exploiter_plugin.py new file mode 100644 index 000000000..439dfdb7e --- /dev/null +++ b/testing/backend/unit/test_xss_exploiter_plugin.py @@ -0,0 +1,110 @@ +"""Parser and contract coverage for plugins/xss_exploiter (issue #520).""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.executor import executor +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "xss_exploiter" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / PLUGIN_ID / "sample_output.txt" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" + + +def _load_xss_exploiter_parser(): + spec = importlib.util.spec_from_file_location("xss_exploiter_parser", PARSER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def plugin_manager(setup_test_environment) -> PluginManager: + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + return manager + + +def test_xss_exploiter_metadata_loads_through_validation_path(plugin_manager): + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "XSS Exploiter" + assert plugin.category == "exploit" + assert plugin.safety.get("level") == "exploit" + assert plugin.safety.get("requires_consent") is True + + schema = plugin_manager.get_plugin_schema(PLUGIN_ID) + assert schema is not None + field_ids = {field["id"] for field in schema["fields"]} + assert "target" in field_ids + + +def test_xss_exploiter_build_command_renders_representative_target(plugin_manager): + target = "https://vuln.local/search?q=test" + command = plugin_manager.build_command(PLUGIN_ID, {"target": target}) + + assert command is not None + assert command[0] == "python3" + assert command[1] == "-c" + assert target in command + assert "print('XSS probe simulation')" in command[2] + + +def test_xss_exploiter_parser_fixture_produces_stable_findings(plugin_manager): + parser = _load_xss_exploiter_parser() + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + + parsed = parser.parse(raw_output) + assert parsed["count"] == 4 + assert len(parsed["findings"]) == 4 + assert parsed["items"] == [ + "XSS probe simulation", + "target=https://vuln.local/search?q=test", + "payload=<script>alert(1)</script>", + "XSS exploit confirmed on parameter q", + ] + + first = parsed["findings"][0] + assert first["title"] == "Recon/Scan Observation" + assert first["category"] == "Security Scan" + assert first["severity"] == "info" + assert first["description"] == "XSS probe simulation" + assert first["remediation"] + assert first["metadata"]["raw"] == "XSS probe simulation" + + exploit_line = parsed["findings"][-1] + assert exploit_line["severity"] == "high" + assert "exploit" in exploit_line["description"].lower() + + +def test_xss_exploiter_parser_empty_output_is_deterministic(plugin_manager): + parser = _load_xss_exploiter_parser() + parsed = parser.parse("") + + assert parsed["findings"] == [] + assert parsed["count"] == 0 + assert parsed["items"] == [] + + +def test_xss_exploiter_executor_normalizes_parser_fixture(plugin_manager): + parser = _load_xss_exploiter_parser() + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + + parsed = parser.parse(FIXTURE_PATH.read_text(encoding="utf-8")) + normalized = executor._normalize_parsed_result(plugin, FIXTURE_PATH.read_text(encoding="utf-8"), parsed) + + assert normalized["count"] == 4 + assert len(normalized["findings"]) == 4 + assert normalized["findings"][0]["severity"] == "info" + assert normalized["findings"][-1]["severity"] == "high" + assert all(f["title"] for f in normalized["findings"]) + assert all(f["category"] for f in normalized["findings"]) diff --git a/testing/backend/unit/test_zap_scanner_plugin.py b/testing/backend/unit/test_zap_scanner_plugin.py new file mode 100644 index 000000000..3de4c63fd --- /dev/null +++ b/testing/backend/unit/test_zap_scanner_plugin.py @@ -0,0 +1,119 @@ +"""Parser and contract coverage for plugins/zap_scanner (issue #521).""" + +from __future__ import annotations + +import asyncio +import importlib.util +from pathlib import Path + +import pytest + +from backend.secuscan.config import settings +from backend.secuscan.executor import executor +from backend.secuscan.plugins import PluginManager + +PLUGIN_ID = "zap_scanner" +FIXTURE_PATH = Path(__file__).parent / "fixtures" / PLUGIN_ID / "sample_output.txt" +PARSER_PATH = Path(settings.plugins_dir) / PLUGIN_ID / "parser.py" + + +def _load_zap_scanner_parser(): + spec = importlib.util.spec_from_file_location("zap_scanner_parser", PARSER_PATH) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def plugin_manager(setup_test_environment) -> PluginManager: + manager = PluginManager(settings.plugins_dir) + asyncio.run(manager.load_plugins()) + return manager + + +def test_zap_scanner_metadata_loads_through_validation_path(plugin_manager): + plugin = plugin_manager.get_plugin(PLUGIN_ID) + + assert plugin is not None + assert plugin.id == PLUGIN_ID + assert plugin.name == "DAST Web Proxy (ZAP)" + assert plugin.category == "vulnerability" + assert plugin.safety.get("level") == "exploit" + assert plugin.safety.get("requires_consent") is True + + schema = plugin_manager.get_plugin_schema(PLUGIN_ID) + assert schema is not None + + field_ids = {field["id"] for field in schema["fields"]} + assert "target" in field_ids + + +def test_zap_scanner_build_command_renders_representative_target(plugin_manager): + target = "https://secuscan.in" + + command = plugin_manager.build_command( + PLUGIN_ID, + {"target": target}, + ) + + assert command is not None + assert command[0] == "python3" + assert command[1] == "-c" + assert target in command + assert "ZAP connector placeholder scan" in command[2] + + +def test_zap_scanner_parser_fixture_produces_stable_findings(plugin_manager): + parser = _load_zap_scanner_parser() + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + + parsed = parser.parse(raw_output) + + assert parsed["count"] == 3 + assert len(parsed["findings"]) == 3 + + assert parsed["items"] == [ + "ZAP connector placeholder scan", + "target=https://secuscan.in", + "mode=dast", + ] + + first = parsed["findings"][0] + + assert first["title"] == "Recon/Scan Observation" + assert first["category"] == "Security Scan" + assert first["severity"] == "info" + assert first["metadata"]["raw"] == "ZAP connector placeholder scan" + + +def test_zap_scanner_parser_empty_output_is_deterministic(plugin_manager): + parser = _load_zap_scanner_parser() + + parsed = parser.parse("") + + assert parsed["findings"] == [] + assert parsed["count"] == 0 + assert parsed["items"] == [] + + +def test_zap_scanner_executor_normalizes_parser_fixture(plugin_manager): + parser = _load_zap_scanner_parser() + + plugin = plugin_manager.get_plugin(PLUGIN_ID) + assert plugin is not None + + raw_output = FIXTURE_PATH.read_text(encoding="utf-8") + parsed = parser.parse(raw_output) + + normalized = executor._normalize_parsed_result( + plugin, + raw_output, + parsed, + ) + + assert normalized["count"] == 3 + assert len(normalized["findings"]) == 3 + + assert all(f["title"] for f in normalized["findings"]) + assert all(f["category"] for f in normalized["findings"]) \ No newline at end of file diff --git a/wordlists/README.md b/wordlists/README.md index 864c00e65..a638a6fbd 100644 --- a/wordlists/README.md +++ b/wordlists/README.md @@ -41,10 +41,16 @@ Place your own wordlists in this directory: ## Usage in SecuScan -The Directory Discovery plugin automatically uses these wordlists based on the selected preset: -- **Quick** → small.txt -- **Standard** → medium.txt -- **Deep** → large.txt +The Directory Discovery plugin selects a wordlist by name. Only `small.txt` is +bundled with SecuScan; `medium` and `large` resolve to files you install into this +directory (see above), and a scan that references a missing wordlist will fail. + +The wordlist field **defaults to the bundled `small` list** so an out-of-the-box +scan stays fast and low-traffic. The built-in presets select: +- **Default** (no preset) → `small.txt` (bundled) +- **Quick** → `small.txt` (bundled) +- **Standard** → `medium.txt` (install required) +- **Deep** → `large.txt` (install required) ## Format